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.

575 lines
17 KiB
Rust

//! 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<Body> {
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<Body> {
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", &sections)
.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", &sections)
.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", &sections)
.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", &sections)
.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", &sections)
.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", &sections)
.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", &sections)
.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", &sections)
.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", &sections)
.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"
);
}