Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 278 additions & 1 deletion crates/warpgrid-host/tests/integration_t3_go_http_postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use std::process::Command;
use std::sync::{Arc, OnceLock};
use std::time::Duration;

use wasmtime::component::Component;
use wasmtime::component::{Component, Val};
use wasmtime::Store;

use warpgrid_host::db_proxy::host::DbProxyHost;
Expand Down Expand Up @@ -757,3 +757,280 @@ async fn test_t3_connection_returned_to_pool_on_close() {
assert_eq!(stats.idle, 1, "connection should be idle (pooled)");
assert_eq!(stats.active, 0, "no connections should be active");
}

// ── HTTP-level Integration Tests ──────────────────────────────────
//
// These tests validate the HTTP request/response cycle by calling
// the guest's HTTP-level functions that simulate the Go handler:
// connect to DB → execute query → format JSON response with status code.

/// Helper: extract (status, content_type, body) from an http-response record Val.
fn extract_http_response(results: &[Val]) -> (u16, String, String) {
match &results[0] {
Val::Result(r) => match r.as_ref() {
Ok(Some(val)) => extract_http_record(val),
Ok(None) => panic!("result Ok but no value"),
Err(Some(val)) => {
if let Val::String(s) = val.as_ref() {
panic!("guest returned error: {s}");
} else {
panic!("guest returned error: {:?}", val);
}
}
Err(None) => panic!("result Err but no value"),
},
other => panic!("expected Result val, got {:?}", other),
}
}

fn extract_http_record(val: &Val) -> (u16, String, String) {
if let Val::Record(fields) = val {
let field_vals: Vec<_> = fields.iter().collect();
let status = match &field_vals[0].1 {
Val::U16(v) => *v,
other => panic!("expected u16 for status, got {:?}", other),
};
let content_type = match &field_vals[1].1 {
Val::String(v) => v.to_string(),
other => panic!("expected string for content-type, got {:?}", other),
};
let body = match &field_vals[2].1 {
Val::String(v) => v.to_string(),
other => panic!("expected string for body, got {:?}", other),
};
(status, content_type, body)
} else {
panic!("expected Record val for http-response, got {:?}", val);
}
}

/// Test: GET /users returns HTTP 200 with JSON array of 5 seed users.
#[tokio::test(flavor = "multi_thread")]
async fn test_t3_http_get_users_returns_200_json() {
let mock_pg = MockPostgresServer::start();
let wasm_bytes = build_guest_component();
let engine = WarpGridEngine::new(ShimConfig::default()).unwrap();
let component = Component::new(engine.engine(), wasm_bytes).unwrap();

let factory = Arc::new(TcpConnectionFactory::plain(
Duration::from_secs(5),
Duration::from_millis(2000),
));
let pool_manager = Arc::new(ConnectionPoolManager::new(test_pool_config(), factory));
let host_state = test_host_state(pool_manager);
let mut store = Store::new(engine.engine(), host_state);

let instance = engine
.linker()
.instantiate_async(&mut store, &component)
.await
.unwrap();

let func = instance
.get_func(&mut store, "test-http-get-users")
.expect("test-http-get-users function should exist");

let params = [
Val::String("127.0.0.1".into()),
Val::U16(mock_pg.addr.port()),
Val::String("testdb".into()),
Val::String("testuser".into()),
];
let mut results = vec![Val::Bool(false)]; // placeholder
func.call_async(&mut store, &params, &mut results)
.await
.unwrap();
func.post_return_async(&mut store).await.unwrap();

let (status, content_type, body) = extract_http_response(&results);

assert_eq!(status, 200, "GET /users should return HTTP 200");
assert_eq!(
content_type, "application/json",
"Content-Type should be application/json"
);

// Verify JSON body contains all 5 seed users
for (_, name, email) in &SEED_USERS {
assert!(
body.contains(name),
"response body should contain user '{name}'"
);
assert!(
body.contains(email),
"response body should contain email '{email}'"
);
}

// Verify it looks like a JSON array
assert!(body.starts_with('['), "body should be a JSON array");
assert!(body.ends_with(']'), "body should end with ]");
}

/// Test: POST /users returns HTTP 201 with inserted user as JSON.
#[tokio::test(flavor = "multi_thread")]
async fn test_t3_http_post_user_returns_201_json() {
let mock_pg = MockPostgresServer::start();
let wasm_bytes = build_guest_component();
let engine = WarpGridEngine::new(ShimConfig::default()).unwrap();
let component = Component::new(engine.engine(), wasm_bytes).unwrap();

let factory = Arc::new(TcpConnectionFactory::plain(
Duration::from_secs(5),
Duration::from_millis(2000),
));
let pool_manager = Arc::new(ConnectionPoolManager::new(test_pool_config(), factory));
let host_state = test_host_state(pool_manager);
let mut store = Store::new(engine.engine(), host_state);

let instance = engine
.linker()
.instantiate_async(&mut store, &component)
.await
.unwrap();

let func = instance
.get_func(&mut store, "test-http-post-user")
.expect("test-http-post-user function should exist");

let request_body = r#"{"name":"Test User","email":"test@example.com"}"#;
let params = [
Val::String("127.0.0.1".into()),
Val::U16(mock_pg.addr.port()),
Val::String("testdb".into()),
Val::String("testuser".into()),
Val::String(request_body.into()),
];
let mut results = vec![Val::Bool(false)];
func.call_async(&mut store, &params, &mut results)
.await
.unwrap();
func.post_return_async(&mut store).await.unwrap();

let (status, content_type, body) = extract_http_response(&results);

assert_eq!(status, 201, "POST /users should return HTTP 201");
assert_eq!(
content_type, "application/json",
"Content-Type should be application/json"
);
assert!(
body.contains("Test User"),
"response should contain inserted user name"
);
assert!(
body.contains("test@example.com"),
"response should contain inserted user email"
);
}

/// Test: POST /users with malformed JSON returns HTTP 400.
#[tokio::test(flavor = "multi_thread")]
async fn test_t3_http_post_malformed_json_returns_400() {
let wasm_bytes = build_guest_component();
let engine = WarpGridEngine::new(ShimConfig::default()).unwrap();
let component = Component::new(engine.engine(), wasm_bytes).unwrap();

// No mock Postgres needed — invalid JSON is rejected before DB access
let factory = Arc::new(TcpConnectionFactory::plain(
Duration::from_secs(5),
Duration::from_millis(2000),
));
let pool_manager = Arc::new(ConnectionPoolManager::new(test_pool_config(), factory));
let host_state = test_host_state(pool_manager);
let mut store = Store::new(engine.engine(), host_state);

let instance = engine
.linker()
.instantiate_async(&mut store, &component)
.await
.unwrap();

let func = instance
.get_func(&mut store, "test-http-post-invalid-json")
.expect("test-http-post-invalid-json function should exist");

let params = [Val::String("not-json".into())];
let mut results = vec![Val::Bool(false)];
func.call_async(&mut store, &params, &mut results)
.await
.unwrap();
func.post_return_async(&mut store).await.unwrap();

let (status, content_type, body) = extract_http_response(&results);

assert_eq!(status, 400, "malformed JSON should return HTTP 400");
assert_eq!(
content_type, "application/json",
"Content-Type should be application/json"
);
assert!(
body.contains("Invalid JSON"),
"error body should contain 'Invalid JSON', got: {body}"
);
}

/// Test: GET /users when DB is unavailable returns HTTP 503.
#[tokio::test(flavor = "multi_thread")]
async fn test_t3_http_db_unavailable_returns_503() {
let wasm_bytes = build_guest_component();
let engine = WarpGridEngine::new(ShimConfig::default()).unwrap();
let component = Component::new(engine.engine(), wasm_bytes).unwrap();

// Use a port with no server listening to simulate DB down
let factory = Arc::new(TcpConnectionFactory::plain(
Duration::from_secs(1),
Duration::from_millis(500),
));
let pool_manager = Arc::new(ConnectionPoolManager::new(
PoolConfig {
max_size: 10,
idle_timeout: Duration::from_secs(300),
health_check_interval: Duration::from_secs(30),
connect_timeout: Duration::from_millis(500),
recv_timeout: Duration::from_secs(1),
use_tls: false,
verify_certificates: false,
drain_timeout: Duration::from_secs(5),
},
factory,
));
let host_state = test_host_state(pool_manager);
let mut store = Store::new(engine.engine(), host_state);

let instance = engine
.linker()
.instantiate_async(&mut store, &component)
.await
.unwrap();

let func = instance
.get_func(&mut store, "test-http-db-unavailable")
.expect("test-http-db-unavailable function should exist");

// Port 1 is almost certainly not running a Postgres server
let params = [
Val::String("127.0.0.1".into()),
Val::U16(1),
];
let mut results = vec![Val::Bool(false)];
func.call_async(&mut store, &params, &mut results)
.await
.unwrap();
func.post_return_async(&mut store).await.unwrap();

let (status, content_type, body) = extract_http_response(&results);

assert_eq!(
status, 503,
"DB unavailable should return HTTP 503, got {status}"
);
assert_eq!(
content_type, "application/json",
"Content-Type should be application/json"
);
assert!(
body.contains("Service Unavailable"),
"error body should contain 'Service Unavailable', got: {body}"
);
}
28 changes: 28 additions & 0 deletions test-apps/t3-go-http-postgres/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
75 changes: 75 additions & 0 deletions test-apps/t3-go-http-postgres/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package main

import (
"encoding/json"
"net/http"
)

// seedUsers holds the in-memory user store used by the standalone handler.
var seedUsers []User

// nextID tracks the next auto-increment ID for new users.
var nextID int

// healthResponse is the JSON shape returned by GET /health.
type healthResponse struct {
Status string `json:"status"`
}

// errorResponse is the JSON shape returned for error conditions.
type errorResponse struct {
Error string `json:"error"`
}

func init() {
seedUsers = []User{
{ID: 1, Name: "Alice Johnson", Email: "alice@example.com"},
{ID: 2, Name: "Bob Smith", Email: "bob@example.com"},
{ID: 3, Name: "Carol Williams", Email: "carol@example.com"},
{ID: 4, Name: "Dave Brown", Email: "dave@example.com"},
{ID: 5, Name: "Eve Davis", Email: "eve@example.com"},
}
nextID = 6
}

// handler is the standalone HTTP handler that routes requests and uses
// in-memory storage. It is used by unit tests and the standalone binary mode.
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-App-Name", "t3-go-http-postgres")
w.Header().Set("Content-Type", "application/json")

switch {
case r.URL.Path == "/health" && r.Method == http.MethodGet:
json.NewEncoder(w).Encode(healthResponse{Status: "ok"})

case r.URL.Path == "/users" && r.Method == http.MethodGet:
json.NewEncoder(w).Encode(seedUsers)

case r.URL.Path == "/users" && r.Method == http.MethodPost:
var input struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(errorResponse{Error: "Invalid JSON"})
return
}
if input.Name == "" || input.Email == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(errorResponse{Error: "Missing required fields: name and email"})
return
}

user := User{ID: nextID, Name: input.Name, Email: input.Email}
nextID++
seedUsers = append(seedUsers, user)

w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)

default:
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(errorResponse{Error: "Not Found"})
}
}
Loading
Loading