//! Integration tests for Phase 7: Email + Export endpoints. //! //! Tests: //! - POST /api/v1/syntheses/:id/send-email — send synthesis by email //! - GET /api/v1/syntheses/:id/export/markdown — export as Markdown //! - GET /api/v1/syntheses/:id/export/pdf — export as PDF //! //! Covers authentication, validation, ownership isolation, content-type //! headers, and response body content for all three endpoints. //! //! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run. mod common; use axum::body::Body; use axum::http::{Method, Request, StatusCode}; use ai_synth_backend::services::auth::SESSION_COOKIE_NAME; fn require_test_db() -> bool { std::env::var("TEST_DATABASE_URL").is_ok() } /// Helper: build a sample sections JSON value with two sections and three items. fn sample_sections() -> serde_json::Value { serde_json::json!([ { "title": "Annonces Majeures", "items": [ { "title": "OpenAI lance GPT-5", "url": "https://openai.com/gpt5", "summary": "OpenAI a annonce GPT-5 avec des capacites ameliorees." }, { "title": "Google DeepMind publie Gemini 3", "url": "https://deepmind.google/gemini3", "summary": "DeepMind presente Gemini 3, son nouveau modele multimodal." } ] }, { "title": "Recherche", "items": [ { "title": "Nouveau papier sur le RLHF", "url": "https://arxiv.org/abs/2026.12345", "summary": "Une nouvelle approche du RLHF prometteuse." } ] } ]) } /// Build a GET request with a session cookie. fn authed_get(uri: &str, session: &str) -> Request { Request::builder() .method(Method::GET) .uri(uri) .header("Cookie", format!("{}={}", SESSION_COOKIE_NAME, session)) .body(Body::empty()) .unwrap() } /// Build a GET request without a session cookie. fn unauthed_get(uri: &str) -> Request { Request::builder() .method(Method::GET) .uri(uri) .body(Body::empty()) .unwrap() } // ═══════════════════════════════════════════════════════════════════════════ // Email Endpoint — POST /api/v1/syntheses/:id/send-email (5 tests) // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn send_email_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 body = serde_json::json!({ "email": "test@example.com" }); let (status, resp) = app .post(&format!("/api/v1/syntheses/{}/send-email", fake_id), &body) .await; assert_eq!( status, StatusCode::UNAUTHORIZED, "POST /syntheses/:id/send-email without auth should return 401" ); assert_eq!(resp["error"], "unauthorized"); } #[tokio::test] async fn send_email_with_valid_email_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("email-ok@example.com") .await; let sections = sample_sections(); let synth_id = app .insert_test_synthesis(user_id, "2026-W12", §ions) .await; let body = serde_json::json!({ "email": "recipient@example.com" }); let (status, resp) = app .post_with_session( &format!("/api/v1/syntheses/{}/send-email", synth_id), &body, &session, ) .await; assert_eq!( status, StatusCode::OK, "POST /syntheses/:id/send-email with valid email should return 200" ); assert!( resp["message"].as_str().is_some(), "Response should contain a success message" ); } #[tokio::test] async fn send_email_with_invalid_email_returns_422() { 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("email-invalid@example.com") .await; let sections = sample_sections(); let synth_id = app .insert_test_synthesis(user_id, "2026-W12", §ions) .await; let body = serde_json::json!({ "email": "not-an-email" }); let (status, resp) = app .post_with_session( &format!("/api/v1/syntheses/{}/send-email", synth_id), &body, &session, ) .await; assert_eq!( status, StatusCode::UNPROCESSABLE_ENTITY, "POST /syntheses/:id/send-email with invalid email should return 422" ); assert_eq!(resp["error"], "validation_error"); } #[tokio::test] async fn send_email_for_non_owned_synthesis_returns_404() { 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("email-owner-a@example.com") .await; let (_user_b_id, session_b) = app .create_authenticated_user("email-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 tries to send email for User A's synthesis let body = serde_json::json!({ "email": "recipient@example.com" }); let (status, resp) = app .post_with_session( &format!("/api/v1/syntheses/{}/send-email", synth_id), &body, &session_b, ) .await; assert_eq!( status, StatusCode::NOT_FOUND, "User B should not be able to send email for User A's synthesis" ); assert_eq!(resp["error"], "not_found"); } #[tokio::test] async fn send_email_for_nonexistent_synthesis_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("email-404@example.com") .await; let fake_id = uuid::Uuid::new_v4(); let body = serde_json::json!({ "email": "recipient@example.com" }); let (status, resp) = app .post_with_session( &format!("/api/v1/syntheses/{}/send-email", fake_id), &body, &session, ) .await; assert_eq!( status, StatusCode::NOT_FOUND, "POST /syntheses/:id/send-email for non-existent synthesis should return 404" ); assert_eq!(resp["error"], "not_found"); } // ═══════════════════════════════════════════════════════════════════════════ // Markdown Export — GET /api/v1/syntheses/:id/export/markdown (4 tests) // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn export_markdown_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 req = unauthed_get(&format!("/api/v1/syntheses/{}/export/markdown", fake_id)); let response = app.raw_request(req).await; assert_eq!( response.status(), StatusCode::UNAUTHORIZED, "GET /syntheses/:id/export/markdown without auth should return 401" ); } #[tokio::test] async fn export_markdown_returns_200_with_correct_content_type() { 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("md-export@example.com") .await; let sections = sample_sections(); let synth_id = app .insert_test_synthesis(user_id, "2026-W12", §ions) .await; let req = authed_get( &format!("/api/v1/syntheses/{}/export/markdown", synth_id), &session, ); let (status, _text, headers) = app.raw_request_text(req).await; assert_eq!( status, StatusCode::OK, "GET /syntheses/:id/export/markdown should return 200" ); let content_type = headers .get("content-type") .expect("Should have Content-Type header") .to_str() .unwrap(); assert!( content_type.contains("text/markdown"), "Content-Type should be text/markdown, got: {}", content_type ); let content_disposition = headers .get("content-disposition") .expect("Should have Content-Disposition header") .to_str() .unwrap(); assert!( content_disposition.contains("attachment"), "Content-Disposition should indicate attachment download" ); assert!( content_disposition.contains(".md"), "Content-Disposition filename should end with .md" ); } #[tokio::test] async fn export_markdown_body_contains_sections_and_items() { 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("md-content@example.com") .await; let sections = sample_sections(); let synth_id = app .insert_test_synthesis(user_id, "2026-W12", §ions) .await; let req = authed_get( &format!("/api/v1/syntheses/{}/export/markdown", synth_id), &session, ); let (status, text, _headers) = app.raw_request_text(req).await; assert_eq!(status, StatusCode::OK); // Check title header assert!( text.contains("# Synthese de la Semaine 2026-W12"), "Markdown should contain the synthesis title" ); // Check section titles assert!( text.contains("## Annonces Majeures"), "Markdown should contain section title 'Annonces Majeures'" ); assert!( text.contains("## Recherche"), "Markdown should contain section title 'Recherche'" ); // Check item titles assert!( text.contains("### OpenAI lance GPT-5"), "Markdown should contain item title 'OpenAI lance GPT-5'" ); assert!( text.contains("### Google DeepMind publie Gemini 3"), "Markdown should contain item title 'Google DeepMind publie Gemini 3'" ); assert!( text.contains("### Nouveau papier sur le RLHF"), "Markdown should contain item title 'Nouveau papier sur le RLHF'" ); // Check URLs are present as links assert!( text.contains("https://openai.com/gpt5"), "Markdown should contain item URL" ); assert!( text.contains("https://arxiv.org/abs/2026.12345"), "Markdown should contain item URL" ); // Check summaries assert!( text.contains("OpenAI a annonce GPT-5"), "Markdown should contain item summary" ); } #[tokio::test] async fn export_markdown_for_non_owned_synthesis_returns_404() { 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("md-owner-a@example.com") .await; let (_user_b_id, session_b) = app .create_authenticated_user("md-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 tries to export User A's synthesis let req = authed_get( &format!("/api/v1/syntheses/{}/export/markdown", synth_id), &session_b, ); let (status, _text, _headers) = app.raw_request_text(req).await; assert_eq!( status, StatusCode::NOT_FOUND, "User B should not be able to export User A's synthesis as Markdown" ); } // ═══════════════════════════════════════════════════════════════════════════ // PDF Export — GET /api/v1/syntheses/:id/export/pdf (4 tests) // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn export_pdf_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 req = unauthed_get(&format!("/api/v1/syntheses/{}/export/pdf", fake_id)); let response = app.raw_request(req).await; assert_eq!( response.status(), StatusCode::UNAUTHORIZED, "GET /syntheses/:id/export/pdf without auth should return 401" ); } #[tokio::test] async fn export_pdf_returns_200_with_correct_content_type() { 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("pdf-export@example.com") .await; let sections = sample_sections(); let synth_id = app .insert_test_synthesis(user_id, "2026-W12", §ions) .await; let req = authed_get( &format!("/api/v1/syntheses/{}/export/pdf", synth_id), &session, ); let (status, bytes, headers) = app.raw_request_bytes(req).await; assert_eq!( status, StatusCode::OK, "GET /syntheses/:id/export/pdf should return 200" ); let content_type = headers .get("content-type") .expect("Should have Content-Type header") .to_str() .unwrap(); assert_eq!( content_type, "application/pdf", "Content-Type should be application/pdf" ); let content_disposition = headers .get("content-disposition") .expect("Should have Content-Disposition header") .to_str() .unwrap(); assert!( content_disposition.contains("attachment"), "Content-Disposition should indicate attachment download" ); assert!( content_disposition.contains(".pdf"), "Content-Disposition filename should end with .pdf" ); // Also verify PDF is non-empty assert!( !bytes.is_empty(), "PDF response body should not be empty" ); } #[tokio::test] async fn export_pdf_body_starts_with_pdf_magic_bytes() { 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("pdf-magic@example.com") .await; let sections = sample_sections(); let synth_id = app .insert_test_synthesis(user_id, "2026-W12", §ions) .await; let req = authed_get( &format!("/api/v1/syntheses/{}/export/pdf", synth_id), &session, ); let (status, bytes, _headers) = app.raw_request_bytes(req).await; assert_eq!(status, StatusCode::OK); assert!( bytes.starts_with(b"%PDF"), "PDF body should start with %PDF magic bytes, got first 4 bytes: {:?}", &bytes[..std::cmp::min(4, bytes.len())] ); } #[tokio::test] async fn export_pdf_for_non_owned_synthesis_returns_404() { 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("pdf-owner-a@example.com") .await; let (_user_b_id, session_b) = app .create_authenticated_user("pdf-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 tries to export User A's synthesis as PDF let req = authed_get( &format!("/api/v1/syntheses/{}/export/pdf", synth_id), &session_b, ); let (status, _bytes, _headers) = app.raw_request_bytes(req).await; assert_eq!( status, StatusCode::NOT_FOUND, "User B should not be able to export User A's synthesis as PDF" ); }