//! Integration tests for the syntheses endpoints (Phase 5). //! //! Tests: //! - GET /api/v1/syntheses — list user's syntheses (paginated) //! - GET /api/v1/syntheses/:id — get synthesis detail //! - DELETE /api/v1/syntheses/:id — delete a synthesis //! - POST /api/v1/syntheses/generate — trigger generation //! //! Covers authentication, CRUD, ownership isolation, pagination, //! and generation trigger behaviour. //! //! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run. mod common; use axum::body::Body; use axum::http::StatusCode; fn require_test_db() -> bool { std::env::var("TEST_DATABASE_URL").is_ok() } /// Helper: build a sample sections JSON value with one section and two items. fn sample_sections() -> serde_json::Value { serde_json::json!([ { "title": "AI News", "items": [ { "title": "Article 1", "url": "https://example.com/1", "summary": "Summary of article 1" }, { "title": "Article 2", "url": "https://example.com/2", "summary": "Summary of article 2" } ] } ]) } /// Helper: build a multi-section JSON value. fn multi_sections() -> serde_json::Value { serde_json::json!([ { "title": "Major Announcements", "items": [ { "title": "Big Launch", "url": "https://example.com/launch", "summary": "A big product launch" } ] }, { "title": "Research", "items": [ { "title": "New Paper", "url": "https://example.com/paper", "summary": "A new research paper" }, { "title": "Breakthrough", "url": "https://example.com/breakthrough", "summary": "A scientific breakthrough" } ] } ]) } // ═══════════════════════════════════════════════════════════════════════════ // Auth (3 tests) // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn get_syntheses_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (status, body) = app.get("/api/v1/syntheses").await; assert_eq!( status, StatusCode::UNAUTHORIZED, "GET /syntheses without auth should return 401" ); assert_eq!(body["error"], "unauthorized"); } #[tokio::test] async fn get_synthesis_by_id_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let fake_id = uuid::Uuid::new_v4(); let (status, body) = app .get(&format!("/api/v1/syntheses/{}", fake_id)) .await; assert_eq!( status, StatusCode::UNAUTHORIZED, "GET /syntheses/:id without auth should return 401" ); assert_eq!(body["error"], "unauthorized"); } #[tokio::test] async fn delete_synthesis_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let fake_id = uuid::Uuid::new_v4(); // DELETE without session — use raw request to include CSRF header let req = axum::http::Request::builder() .method(axum::http::Method::DELETE) .uri(&format!("/api/v1/syntheses/{}", fake_id)) .header("X-Requested-With", "XMLHttpRequest") .body(axum::body::Body::empty()) .unwrap(); let response = app.raw_request(req).await; assert_eq!( response.status(), StatusCode::UNAUTHORIZED, "DELETE /syntheses/:id without auth should return 401" ); } // ═══════════════════════════════════════════════════════════════════════════ // CRUD (6 tests) // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn get_syntheses_returns_empty_list_initially() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("synth-empty@example.com") .await; let (status, body) = app .get_with_session("/api/v1/syntheses", &session) .await; assert_eq!(status, StatusCode::OK); let items = body["items"].as_array().expect("items should be an array"); assert!(items.is_empty(), "New user should have no syntheses"); } #[tokio::test] async fn get_syntheses_returns_inserted_synthesis() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (user_id, session) = app .create_authenticated_user("synth-list@example.com") .await; // Insert a synthesis directly via the helper let sections = sample_sections(); let synth_id = app .insert_test_synthesis(user_id, "2026-W12", §ions) .await; let (status, body) = app .get_with_session("/api/v1/syntheses", &session) .await; assert_eq!(status, StatusCode::OK); let items = body["items"].as_array().expect("items array"); assert_eq!(items.len(), 1, "Should have exactly 1 synthesis"); let item = &items[0]; assert_eq!(item["id"], synth_id.to_string()); assert_eq!(item["week"], "2026-W12"); assert_eq!(item["status"], "completed"); // first_section_title should be "AI News" assert_eq!(item["first_section_title"], "AI News"); assert_eq!(item["first_section_item_count"], 2); } #[tokio::test] async fn get_synthesis_by_id_returns_full_detail() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (user_id, session) = app .create_authenticated_user("synth-detail@example.com") .await; let sections = multi_sections(); let synth_id = app .insert_test_synthesis(user_id, "2026-W11", §ions) .await; let (status, body) = app .get_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session) .await; assert_eq!(status, StatusCode::OK); assert_eq!(body["id"], synth_id.to_string()); assert_eq!(body["week"], "2026-W11"); assert_eq!(body["status"], "completed"); let resp_sections = body["sections"].as_array().expect("sections array"); assert_eq!(resp_sections.len(), 2); assert_eq!(resp_sections[0]["title"], "Major Announcements"); assert_eq!( resp_sections[0]["items"].as_array().unwrap().len(), 1 ); assert_eq!(resp_sections[1]["title"], "Research"); assert_eq!( resp_sections[1]["items"].as_array().unwrap().len(), 2 ); // Verify nested item details let first_item = &resp_sections[0]["items"][0]; assert_eq!(first_item["title"], "Big Launch"); assert_eq!(first_item["url"], "https://example.com/launch"); assert_eq!(first_item["summary"], "A big product launch"); } #[tokio::test] async fn get_synthesis_nonexistent_returns_404() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("synth-404@example.com") .await; let fake_id = uuid::Uuid::new_v4(); let (status, body) = app .get_with_session(&format!("/api/v1/syntheses/{}", fake_id), &session) .await; assert_eq!( status, StatusCode::NOT_FOUND, "Non-existent synthesis should return 404" ); assert_eq!(body["error"], "not_found"); } #[tokio::test] async fn delete_synthesis_returns_204() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (user_id, session) = app .create_authenticated_user("synth-delete@example.com") .await; let sections = sample_sections(); let synth_id = app .insert_test_synthesis(user_id, "2026-W12", §ions) .await; let (status, _) = app .delete_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session) .await; assert_eq!( status, StatusCode::NO_CONTENT, "DELETE /syntheses/:id should return 204" ); } #[tokio::test] async fn delete_then_get_returns_empty_list() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (user_id, session) = app .create_authenticated_user("synth-del-list@example.com") .await; let sections = sample_sections(); let synth_id = app .insert_test_synthesis(user_id, "2026-W12", §ions) .await; // Delete it let (del_status, _) = app .delete_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session) .await; assert_eq!(del_status, StatusCode::NO_CONTENT); // List should be empty now let (status, body) = app .get_with_session("/api/v1/syntheses", &session) .await; assert_eq!(status, StatusCode::OK); let items = body["items"].as_array().expect("items array"); assert!(items.is_empty(), "After deletion, list should be empty"); // GET by id should also 404 let (get_status, get_body) = app .get_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session) .await; assert_eq!(get_status, StatusCode::NOT_FOUND); assert_eq!(get_body["error"], "not_found"); } // ═══════════════════════════════════════════════════════════════════════════ // Ownership Isolation (2 tests) // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn user_a_syntheses_not_visible_to_user_b() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (user_a_id, session_a) = app .create_authenticated_user("synth-owner-a@example.com") .await; let (_user_b_id, session_b) = app .create_authenticated_user("synth-owner-b@example.com") .await; // Insert a synthesis for User A let sections = sample_sections(); let synth_id = app .insert_test_synthesis(user_a_id, "2026-W12", §ions) .await; // User B lists syntheses — should be empty let (status_b, body_b) = app .get_with_session("/api/v1/syntheses", &session_b) .await; assert_eq!(status_b, StatusCode::OK); let items_b = body_b["items"].as_array().expect("items array"); assert!( items_b.is_empty(), "User B should not see User A's syntheses" ); // User B tries to access User A's synthesis by ID — should 404 let (get_status, get_body) = app .get_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session_b) .await; assert_eq!( get_status, StatusCode::NOT_FOUND, "User B should not access User A's synthesis" ); assert_eq!(get_body["error"], "not_found"); // User A can still see their synthesis let (status_a, body_a) = app .get_with_session("/api/v1/syntheses", &session_a) .await; assert_eq!(status_a, StatusCode::OK); let items_a = body_a["items"].as_array().expect("items array"); assert_eq!(items_a.len(), 1); } #[tokio::test] async fn user_b_cannot_delete_user_a_synthesis() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (user_a_id, session_a) = app .create_authenticated_user("synth-del-a@example.com") .await; let (_user_b_id, session_b) = app .create_authenticated_user("synth-del-b@example.com") .await; // Insert a synthesis for User A let sections = sample_sections(); let synth_id = app .insert_test_synthesis(user_a_id, "2026-W12", §ions) .await; // User B tries to delete User A's synthesis — should 404 let (del_status, del_body) = app .delete_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session_b) .await; assert_eq!( del_status, StatusCode::NOT_FOUND, "User B should not be able to delete User A's synthesis" ); assert_eq!(del_body["error"], "not_found"); // User A's synthesis should still exist let (get_status, _) = app .get_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session_a) .await; assert_eq!( get_status, StatusCode::OK, "User A's synthesis should still exist after User B's delete attempt" ); } // ═══════════════════════════════════════════════════════════════════════════ // Pagination (2 tests) // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn get_syntheses_limit_returns_at_most_n() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (user_id, session) = app .create_authenticated_user("synth-limit@example.com") .await; // Insert 3 syntheses let sections = sample_sections(); for week in &["2026-W10", "2026-W11", "2026-W12"] { app.insert_test_synthesis(user_id, week, §ions).await; } // Request with limit=2 let (status, body) = app .get_with_session("/api/v1/syntheses?limit=2", &session) .await; assert_eq!(status, StatusCode::OK); let items = body["items"].as_array().expect("items array"); assert_eq!( items.len(), 2, "limit=2 should return at most 2 syntheses" ); } #[tokio::test] async fn get_syntheses_offset_skips_first_item() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (user_id, session) = app .create_authenticated_user("synth-offset@example.com") .await; // Insert 3 syntheses (created in order, newest-first in response) let sections = sample_sections(); for week in &["2026-W10", "2026-W11", "2026-W12"] { app.insert_test_synthesis(user_id, week, §ions).await; // Small delay to ensure different created_at timestamps tokio::time::sleep(std::time::Duration::from_millis(10)).await; } // Get all to see the order let (_, all_body) = app .get_with_session("/api/v1/syntheses", &session) .await; let all_items = all_body["items"].as_array().unwrap(); assert_eq!(all_items.len(), 3); // Get with offset=1 — should skip the first (newest) let (status, body) = app .get_with_session("/api/v1/syntheses?offset=1", &session) .await; assert_eq!(status, StatusCode::OK); let items = body["items"].as_array().expect("items array"); assert_eq!(items.len(), 2, "offset=1 should skip 1 and return 2"); // The first item in the offset response should match the second in the full list assert_eq!(items[0]["id"], all_items[1]["id"]); } // ═══════════════════════════════════════════════════════════════════════════ // Generation Trigger (3 tests) // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn generate_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let body = serde_json::json!({}); let (status, resp) = app.post("/api/v1/syntheses/generate", &body).await; assert_eq!( status, StatusCode::UNAUTHORIZED, "POST /syntheses/generate without auth should return 401" ); assert_eq!(resp["error"], "unauthorized"); } #[tokio::test] async fn generate_returns_202_with_job_id() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("synth-gen@example.com") .await; let body = serde_json::json!({}); let (status, resp) = app .post_with_session("/api/v1/syntheses/generate", &body, &session) .await; assert_eq!( status, StatusCode::ACCEPTED, "POST /syntheses/generate should return 202 Accepted" ); // Response should contain a job_id (UUID) let job_id = resp["job_id"].as_str().expect("job_id should be a string"); assert!( uuid::Uuid::parse_str(job_id).is_ok(), "job_id should be a valid UUID" ); assert!( resp["message"].as_str().is_some(), "Response should contain a message" ); } #[tokio::test] async fn generate_twice_returns_error_for_second() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("synth-gen-dup@example.com") .await; let body = serde_json::json!({}); // First call should succeed with 202 let (status1, resp1) = app .post_with_session("/api/v1/syntheses/generate", &body, &session) .await; assert_eq!(status1, StatusCode::ACCEPTED); assert!(resp1["job_id"].as_str().is_some()); // Second call should fail — one job at a time let (status2, resp2) = app .post_with_session("/api/v1/syntheses/generate", &body, &session) .await; assert_eq!( status2, StatusCode::BAD_REQUEST, "Second generate call should be rejected while one is in progress" ); assert_eq!(resp2["error"], "bad_request"); } // ═══════════════════════════════════════════════════════════════════════════ // Generation Pipeline — Model Resolution // ═══════════════════════════════════════════════════════════════════════════ /// Verify that triggering generation with a configured provider exercises /// the model resolution SQL query against the real database schema. /// The generation will fail at the LLM call (fake API key), but that confirms /// settings load, model resolution, provider creation, and schema building /// all succeed — which is the code path that had the admin_provider_models bug. #[tokio::test] async fn generate_pipeline_resolves_model_from_admin_config() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (user_id, session) = app .create_authenticated_user("synth-model-resolve@example.com") .await; // Configure user settings with provider and categories let settings = serde_json::json!({ "categories": ["Test Category"], "ai_provider": "openai", "ai_model": "", "ai_model_writing": "", "use_llm_for_source_links": false, "use_llm_for_article_extraction": false, "article_history_days": 90 }); let (settings_status, _) = app .put_with_session("/api/v1/settings", &settings, &session) .await; assert_eq!(settings_status, StatusCode::OK, "Settings save should succeed"); // Store a fake API key — we don't need a real one, just need to get past // model resolution and into the LLM call let key_body = serde_json::json!({ "provider_name": "openai", "api_key": "sk-fake-test-key-for-model-resolution" }); let (key_status, _) = app .post_with_session("/api/v1/user/api-keys", &key_body, &session) .await; assert_eq!(key_status, StatusCode::OK, "API key store should succeed"); // Add a source so the pipeline has something to work with let source_body = serde_json::json!({ "title": "Test Source", "url": "https://example.com" }); let (source_status, _) = app .post_with_session("/api/v1/sources", &source_body, &session) .await; assert_eq!(source_status, StatusCode::OK, "Source creation should succeed"); // Trigger generation — this will run async. The key assertion is that // the HTTP trigger returns 202 (not 500) and the async job doesn't crash // on a database error. It will fail at the LLM API call (fake key), which // is expected and fine — the model resolution path was exercised. let gen_body = serde_json::json!({}); let (gen_status, gen_resp) = app .post_with_session("/api/v1/syntheses/generate", &gen_body, &session) .await; assert_eq!( gen_status, StatusCode::ACCEPTED, "Generation trigger should succeed (202)" ); let job_id = gen_resp["job_id"].as_str().expect("should have job_id"); // Wait briefly for the async job to attempt model resolution and LLM call tokio::time::sleep(std::time::Duration::from_secs(3)).await; // Poll the progress endpoint — we expect an error from the LLM call (fake key), // NOT a database error about a missing table. This confirms model resolution worked. let progress_url = format!("/api/v1/syntheses/generate/{}/progress", job_id); let req = axum::http::Request::builder() .method(axum::http::Method::GET) .uri(&progress_url) .header("cookie", format!("ai_synth_session={}", session)) .header("x-requested-with", "XMLHttpRequest") .body(Body::empty()) .unwrap(); let (_, progress_text, _) = app.raw_request_text(req).await; // The SSE stream should NOT contain a database error about a missing table. // It should contain an LLM-related error (fake key) instead. assert!( !progress_text.contains("admin_provider_models"), "Generation must not reference non-existent admin_provider_models table. Got: {}", progress_text ); }