diff --git a/backend/src/router.rs b/backend/src/router.rs index 2cf4cb9..81b2b12 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -137,62 +137,6 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router { app } -/// Build a lightweight router for integration tests. -/// -/// Skips static file serving (SPA fallback) and TraceLayer to avoid -/// issues with `oneshot()` in test environments. -pub fn build_test_router(state: AppState, config: &AppConfig) -> Router { - let api_routes = Router::new() - .route("/auth/register", post(handlers::auth::register)) - .route("/auth/login", post(handlers::auth::login)) - .route("/auth/verify", get(handlers::auth::verify_get)) - .route("/auth/verify", post(handlers::auth::verify_post)) - .route("/auth/logout", post(handlers::auth::logout)) - .route("/auth/me", get(handlers::auth::me)) - .route("/settings", get(handlers::settings::get_settings)) - .route("/settings", put(handlers::settings::update_settings)) - .route("/sources", get(handlers::sources::list)) - .route("/sources", post(handlers::sources::create)) - .route("/sources/{id}", delete(handlers::sources::delete)) - .route("/sources/bulk", post(handlers::sources::bulk_import)) - .route("/sources/import-csv", post(handlers::sources::import_csv)) - .route("/sources/export-csv", get(handlers::sources::export_csv)) - .route("/user/api-keys", get(handlers::api_keys::list)) - .route("/user/api-keys", post(handlers::api_keys::create)) - .route("/user/api-keys/{provider}", delete(handlers::api_keys::delete)) - .route("/user/api-keys/{provider}/test", post(handlers::api_keys::test_key)) - .route("/user/api-keys/export", post(handlers::api_keys::export_keys)) - .route("/syntheses/generate", post(handlers::generation::trigger_generate)) - .route("/syntheses/generate/{job_id}/progress", get(handlers::generation::progress_stream)) - .route("/article-history", get(handlers::article_history::list_history).delete(handlers::article_history::clear_history)) - .route("/syntheses/{id}/provenance", get(handlers::article_history::get_provenance)) - .route("/llm-logs/{job_id}", get(handlers::llm_logs::get_logs)) - .route("/syntheses", get(handlers::syntheses::list)) - .route("/syntheses/{id}", get(handlers::syntheses::get)) - .route("/syntheses/{id}", delete(handlers::syntheses::delete)) - .route("/syntheses/{id}/send-email", post(handlers::syntheses::send_email)) - .route("/syntheses/{id}/export/markdown", get(handlers::syntheses::export_markdown)) - .route("/syntheses/{id}/export/pdf", get(handlers::syntheses::export_pdf)) - .route("/config/providers", get(handlers::config::list_enabled_providers)) - .route("/admin/providers", get(handlers::admin::list_providers)) - .route("/admin/providers", post(handlers::admin::create_provider)) - .route("/admin/providers/{id}", put(handlers::admin::update_provider)) - .route("/admin/providers/{id}", delete(handlers::admin::delete_provider)) - .route("/admin/rate-limits", get(handlers::admin::list_rate_limits)) - .route("/admin/rate-limits/{provider_name}", put(handlers::admin::update_rate_limit)) - .route("/admin/users", get(handlers::admin::list_users)) - .route("/admin/users/{id}/role", put(handlers::admin::update_user_role)) - .route("/health", get(handlers::health::health_check)) - .layer(axum_mw::from_fn(csrf::csrf_check)); - - let api = Router::new().nest("/api/v1", api_routes); - - Router::new() - .merge(api) - .with_state(state) - .layer(DefaultBodyLimit::max(1024 * 1024)) -} - /// Build the CORS layer based on configuration. /// /// Allows the configured `APP_URL` as origin, with credentials (cookies). diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 5b99a44..40ebcb4 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -28,7 +28,7 @@ use ai_synth_backend::app_state::AppState; use ai_synth_backend::config::AppConfig; use ai_synth_backend::db; use ai_synth_backend::models::user::UserRole; -use ai_synth_backend::router::build_test_router; +use ai_synth_backend::router::build_router; use ai_synth_backend::services::auth; use ai_synth_backend::services::email::TEST_API_KEY as EMAIL_TEST_KEY; use ai_synth_backend::services::turnstile::TEST_SECRET_KEY as TURNSTILE_TEST_KEY; @@ -119,7 +119,14 @@ impl TestApp { let http_client = reqwest::Client::new(); let state = AppState::new(config.clone(), pool.clone(), http_client); - let router = build_test_router(state, &config); + // Create the static dir so ServeDir/ServeFile don't error + let _ = std::fs::create_dir_all(&config.static_dir); + let index_path = format!("{}/index.html", config.static_dir); + if !std::path::Path::new(&index_path).exists() { + let _ = std::fs::write(&index_path, "test"); + } + + let router = build_router(state, &config); Self { router, diff --git a/backend/tests/minimal_test.rs b/backend/tests/minimal_test.rs new file mode 100644 index 0000000..8d8f804 --- /dev/null +++ b/backend/tests/minimal_test.rs @@ -0,0 +1,26 @@ +/// Minimal test to debug oneshot() hanging issue. +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use axum::{routing::get, Router}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +async fn hello() -> &'static str { + "ok" +} + +#[tokio::test] +async fn minimal_oneshot_works() { + let app: Router = Router::new().route("/test", get(hello)); + + let req: Request = Request::builder() + .uri("/test") + .body(Body::empty()) + .unwrap(); + + let response: axum::http::Response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"ok"); +} diff --git a/scripts/run-integration-tests.sh b/scripts/run-integration-tests.sh index 1efb42c..07a05d1 100755 --- a/scripts/run-integration-tests.sh +++ b/scripts/run-integration-tests.sh @@ -1,66 +1,87 @@ #!/usr/bin/env bash # # Run backend integration tests (Rust). -# Starts a Postgres container, runs tests, then cleans up. +# Uses the e2e/docker-compose.test.yml for Postgres (port 5433). # # Usage: # ./scripts/run-integration-tests.sh # all tests # ./scripts/run-integration-tests.sh --test pipeline_test # one test file # ./scripts/run-integration-tests.sh --test api_admin_test config_providers # one test by name -# ./scripts/run-integration-tests.sh --test api_admin_test -- --test-threads=1 # extra cargo args # ./scripts/run-integration-tests.sh --lib # unit tests only +# ./scripts/run-integration-tests.sh --db-check # just check DB connectivity # -# All arguments are passed directly to `cargo test`. +# All arguments (except --db-check) are passed directly to `cargo test`. # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +E2E_DIR="$PROJECT_DIR/e2e" -CONTAINER_NAME="ai-synth-integration-db" -PG_PORT=5434 -PG_USER="test_user" -PG_PASS="test_password" -PG_DB="postgres" +PG_HOST="127.0.0.1" +PG_PORT=5433 +PG_USER="ai_synth_test" +PG_PASS="testpassword" +PG_DB="ai_synth_test" -cleanup() { - echo "Cleaning up..." - docker rm -f "$CONTAINER_NAME" 2>/dev/null || true -} -trap cleanup EXIT +export TEST_DATABASE_URL="postgres://${PG_USER}:${PG_PASS}@${PG_HOST}:${PG_PORT}/${PG_DB}" -echo "=== Starting Postgres for integration tests ===" -docker rm -f "$CONTAINER_NAME" 2>/dev/null || true -docker run -d \ - --name "$CONTAINER_NAME" \ - -e POSTGRES_USER="$PG_USER" \ - -e POSTGRES_PASSWORD="$PG_PASS" \ - -e POSTGRES_DB="$PG_DB" \ - -p "127.0.0.1:${PG_PORT}:5432" \ - --health-cmd "pg_isready -U $PG_USER -d $PG_DB" \ - --health-interval 5s \ - --health-timeout 3s \ - --health-retries 10 \ - postgres:17-alpine +# ── DB check mode ────────────────────────────────────────────────── +if [ "${1:-}" = "--db-check" ]; then + echo "Checking DB connectivity at ${PG_HOST}:${PG_PORT}..." + if docker exec ai-synth-test-db pg_isready -U "$PG_USER" -d "$PG_DB" 2>/dev/null; then + echo "DB is ready." + echo "Testing SQL query..." + docker exec ai-synth-test-db psql -U "$PG_USER" -d "$PG_DB" -c "SELECT 1 AS ok" 2>&1 + echo "" + echo "Testing connection from host..." + if command -v psql >/dev/null 2>&1; then + PGPASSWORD="$PG_PASS" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -c "SELECT 1 AS ok" 2>&1 || echo "Host psql connection failed" + else + echo "(psql not installed on host, skipping host connection test)" + fi + else + echo "DB container not running. Start it with:" + echo " cd $E2E_DIR && docker compose -f docker-compose.test.yml up -d db" + fi + exit 0 +fi + +# ── Start DB if not running ──────────────────────────────────────── +echo "=== Ensuring test Postgres is running ===" +cd "$E2E_DIR" + +# Start only the DB service (not the app) +docker compose -f docker-compose.test.yml up -d db echo "Waiting for Postgres to be ready..." for i in $(seq 1 30); do - if docker exec "$CONTAINER_NAME" pg_isready -U "$PG_USER" -d "$PG_DB" >/dev/null 2>&1; then - echo "Postgres is ready." + if docker exec ai-synth-test-db pg_isready -U "$PG_USER" -d "$PG_DB" >/dev/null 2>&1; then + echo "Postgres is ready on port ${PG_PORT}." break fi if [ "$i" -eq 30 ]; then echo "ERROR: Postgres did not become ready in time." + docker compose -f docker-compose.test.yml logs db | tail -10 exit 1 fi sleep 1 done -export TEST_DATABASE_URL="postgres://${PG_USER}:${PG_PASS}@127.0.0.1:${PG_PORT}/${PG_DB}" +# Verify connectivity from host +echo "Verifying host connectivity..." +cd "$PROJECT_DIR/backend" +if ! cargo sqlx database exists "$TEST_DATABASE_URL" 2>/dev/null; then + # sqlx-cli not installed, try a simple Rust connection test + echo "(sqlx-cli not available, will rely on cargo test to verify connectivity)" +fi +# ── Run tests ────────────────────────────────────────────────────── +echo "" echo "=== Running integration tests ===" -cd "$PROJECT_DIR/backend" +echo "TEST_DATABASE_URL=$TEST_DATABASE_URL" +echo "" if [ $# -gt 0 ]; then echo "Running: cargo test $*" @@ -70,4 +91,8 @@ else cargo test fi -echo "=== All tests passed ===" +echo "" +echo "=== Tests complete ===" +echo "" +echo "Note: DB container is still running. To stop it:" +echo " cd $E2E_DIR && docker compose -f docker-compose.test.yml down"