//! Integration tests for the stop generation endpoint (GAP-1). //! //! Tests: //! - POST /api/v1/syntheses/generate/:job_id/stop — stop a running job //! //! Covers authentication, ownership isolation, non-existent jobs, //! and stopping an active job. //! //! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run. mod common; use axum::http::StatusCode; fn require_test_db() -> bool { std::env::var("TEST_DATABASE_URL").is_ok() } // ═══════════════════════════════════════════════════════════════════════════ // Auth (1 test) // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn stop_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let fake_job_id = uuid::Uuid::new_v4(); let (status, body) = app .post_with_session( &format!("/api/v1/syntheses/generate/{}/stop", fake_job_id), &serde_json::json!({}), "invalid-session-token", ) .await; assert_eq!( status, StatusCode::UNAUTHORIZED, "POST /syntheses/generate/:id/stop without auth should return 401" ); assert_eq!(body["error"], "unauthorized"); } // ═══════════════════════════════════════════════════════════════════════════ // Not found (1 test) // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn stop_nonexistent_job_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("stop-404@example.com") .await; let fake_job_id = uuid::Uuid::new_v4(); let (status, body) = app .post_with_session( &format!("/api/v1/syntheses/generate/{}/stop", fake_job_id), &serde_json::json!({}), &session, ) .await; assert_eq!( status, StatusCode::NOT_FOUND, "Stopping a non-existent job should return 404" ); assert_eq!(body["error"], "not_found"); } // ═══════════════════════════════════════════════════════════════════════════ // Stop active job (1 test) // ═══════════════════════════════════════════════════════════════════════════ /// Verify that stopping an active generation job returns 200. /// /// The generation will fail at the LLM call (fake API key), but the job_id /// is registered in the job store immediately on trigger, so the stop /// endpoint should find it and return 200. #[tokio::test] async fn stop_active_job_returns_200() { 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("stop-active@example.com") .await; // Configure provider settings let settings = serde_json::json!({ "max_articles_per_source": 3, "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, "batch_size": 5, "source_extraction_window": 3, "search_agent_behavior": "", "ai_provider": "openai", "ai_model": "", "ai_model_websearch": "", "rate_limit_max_requests": null, "rate_limit_time_window_seconds": null }); let (settings_status, _) = app .put_with_session("/api/v1/settings", &settings, &session) .await; assert_eq!(settings_status, StatusCode::OK, "Settings save should succeed"); // Create a theme let theme_body = serde_json::json!({ "name": "Stop Test Theme", "theme": "Intelligence Artificielle", "categories": ["AI News"], "max_items_per_category": 4, "max_age_days": 7, "summary_length": 3 }); let (theme_status, theme_resp) = app .post_with_session("/api/v1/themes", &theme_body, &session) .await; assert_eq!(theme_status.as_u16(), 201, "Theme creation should succeed"); let theme_id = theme_resp["id"].as_str().expect("Theme should have an id"); // Store a fake API key so the pipeline can start let key_body = serde_json::json!({ "provider_name": "openai", "api_key": "sk-fake-test-key-for-stop-test" }); 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 process let source_body = serde_json::json!({ "title": "Stop Test Source", "url": "https://example.com/blog", "theme_id": theme_id }); let (source_status, _) = app .post_with_session("/api/v1/sources", &source_body, &session) .await; assert_eq!(source_status, StatusCode::CREATED, "Source creation should succeed"); // Trigger generation — returns 202 immediately, job runs async let gen_body = serde_json::json!({ "theme_id": theme_id }); 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 return 202" ); let job_id = gen_resp["job_id"].as_str().expect("should have job_id"); // Immediately stop the job — it's registered in the job store at trigger time, // so this should succeed even if generation hasn't finished yet. let (stop_status, _) = app .post_with_session( &format!("/api/v1/syntheses/generate/{}/stop", job_id), &serde_json::json!({}), &session, ) .await; assert_eq!( stop_status, StatusCode::OK, "Stopping an active job should return 200" ); } // ═══════════════════════════════════════════════════════════════════════════ // Ownership isolation (1 test) // ═══════════════════════════════════════════════════════════════════════════ /// Verify that User B cannot stop User A's generation job. /// /// The stop endpoint uses `cancel_job(job_id, user_id)` which checks ownership, /// so it returns 404 if the job exists but belongs to a different user. #[tokio::test] async fn stop_other_users_job_returns_404() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; // User A: create user + settings + theme + api key, then trigger generation let (_user_a_id, session_a) = app .create_authenticated_user("stop-owner-a@example.com") .await; let (_user_b_id, session_b) = app .create_authenticated_user("stop-owner-b@example.com") .await; // Configure User A's settings let settings = serde_json::json!({ "max_articles_per_source": 3, "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, "batch_size": 5, "source_extraction_window": 3, "search_agent_behavior": "", "ai_provider": "openai", "ai_model": "", "ai_model_websearch": "", "rate_limit_max_requests": null, "rate_limit_time_window_seconds": null }); let (settings_status, _) = app .put_with_session("/api/v1/settings", &settings, &session_a) .await; assert_eq!(settings_status, StatusCode::OK, "User A settings save should succeed"); // Create theme for User A let theme_body = serde_json::json!({ "name": "Owner A Theme", "theme": "Intelligence Artificielle", "categories": ["AI News"], "max_items_per_category": 4, "max_age_days": 7, "summary_length": 3 }); let (theme_status, theme_resp) = app .post_with_session("/api/v1/themes", &theme_body, &session_a) .await; assert_eq!(theme_status.as_u16(), 201, "User A theme creation should succeed"); let theme_id_a = theme_resp["id"].as_str().expect("Theme should have an id"); // Store fake API key for User A let key_body = serde_json::json!({ "provider_name": "openai", "api_key": "sk-fake-test-key-for-ownership-test" }); let (key_status, _) = app .post_with_session("/api/v1/user/api-keys", &key_body, &session_a) .await; assert_eq!(key_status, StatusCode::OK, "User A API key store should succeed"); // Add source for User A let source_body = serde_json::json!({ "title": "Owner A Source", "url": "https://example-a.com/blog", "theme_id": theme_id_a }); let (source_status, _) = app .post_with_session("/api/v1/sources", &source_body, &session_a) .await; assert_eq!(source_status, StatusCode::CREATED, "User A source creation should succeed"); // User A triggers generation let gen_body = serde_json::json!({ "theme_id": theme_id_a }); let (gen_status, gen_resp) = app .post_with_session("/api/v1/syntheses/generate", &gen_body, &session_a) .await; assert_eq!(gen_status, StatusCode::ACCEPTED, "User A generation trigger should return 202"); let job_id_a = gen_resp["job_id"].as_str().expect("should have job_id"); // User B tries to stop User A's job — should return 404 (ownership check) let (stop_status, stop_body) = app .post_with_session( &format!("/api/v1/syntheses/generate/{}/stop", job_id_a), &serde_json::json!({}), &session_b, ) .await; assert_eq!( stop_status, StatusCode::NOT_FOUND, "User B should not be able to stop User A's job (expected 404)" ); assert_eq!(stop_body["error"], "not_found"); }