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
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", §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"
|
|
);
|
|
}
|