You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
703 lines
24 KiB
Rust
703 lines
24 KiB
Rust
//! 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_websearch": "",
|
|
"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
|
|
);
|
|
}
|