From 14908cf603b0f074f7e56f6b77ab0afd5c9e469c Mon Sep 17 00:00:00 2001 From: oabrivard Date: Fri, 27 Mar 2026 08:38:41 +0100 Subject: [PATCH] test: add themes CRUD, article history, and assign_category tests Covers GAP-01 (themes API), GAP-02 (article history API), and GAP-04 (assign_category unit tests). Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/services/synthesis.rs | 88 +++++ backend/tests/api_article_history_test.rs | 133 ++++++++ backend/tests/api_themes_test.rs | 376 ++++++++++++++++++++++ 3 files changed, 597 insertions(+) create mode 100644 backend/tests/api_article_history_test.rs create mode 100644 backend/tests/api_themes_test.rs diff --git a/backend/src/services/synthesis.rs b/backend/src/services/synthesis.rs index f212e49..4b349ef 100644 --- a/backend/src/services/synthesis.rs +++ b/backend/src/services/synthesis.rs @@ -1848,4 +1848,92 @@ mod tests { let result = sanitize_error_message(&msg); assert!(result.ends_with("...")); } + + // ── assign_category tests ─────────────────────────────────── + + #[test] + fn assign_category_maps_to_correct_category() { + let response = serde_json::json!({ + "title": "Test Article", + "summary": "Test summary", + "category": "AI News", + "date": "2026-03-25", + "is_article": true + }); + let user_cats = vec!["AI News".to_string(), "Research".to_string()]; + let class_cats = vec![ + "AI News".to_string(), + "Research".to_string(), + "Divers".to_string(), + ]; + let filled = std::collections::HashMap::new(); + + let result = + assign_category(&response, "Fallback Title", &user_cats, &class_cats, &filled, 4); + assert!(result.is_some()); + let (cat_key, cat_name, title, _summary) = result.unwrap(); + assert_eq!(cat_key, "category_0"); + assert_eq!(cat_name, "AI News"); + assert_eq!(title, "Test Article"); + } + + #[test] + fn assign_category_overflows_to_divers() { + let response = serde_json::json!({ + "title": "Overflow Article", + "summary": "...", + "category": "AI News", + "date": "", + "is_article": true + }); + let user_cats = vec!["AI News".to_string()]; + let class_cats = vec!["AI News".to_string(), "Divers".to_string()]; + let mut filled = std::collections::HashMap::new(); + filled.insert("AI News".to_string(), 4usize); // already full + + let result = assign_category(&response, "", &user_cats, &class_cats, &filled, 4); + assert!(result.is_some()); + let (cat_key, cat_name, _, _) = result.unwrap(); + assert_eq!(cat_key, "category_autre"); + assert_eq!(cat_name, "Divers"); + } + + #[test] + fn assign_category_returns_none_when_all_full() { + let response = serde_json::json!({ + "title": "No Room", + "summary": "...", + "category": "AI News", + "date": "", + "is_article": true + }); + let user_cats = vec!["AI News".to_string()]; + let class_cats = vec!["AI News".to_string(), "Divers".to_string()]; + let mut filled = std::collections::HashMap::new(); + filled.insert("AI News".to_string(), 4usize); + filled.insert("Divers".to_string(), 4usize); + + let result = assign_category(&response, "", &user_cats, &class_cats, &filled, 4); + assert!(result.is_none()); + } + + #[test] + fn assign_category_unknown_category_maps_to_divers() { + let response = serde_json::json!({ + "title": "Unknown Cat", + "summary": "...", + "category": "Nonexistent Category", + "date": "", + "is_article": true + }); + let user_cats = vec!["AI News".to_string()]; + let class_cats = vec!["AI News".to_string(), "Divers".to_string()]; + let filled = std::collections::HashMap::new(); + + let result = assign_category(&response, "", &user_cats, &class_cats, &filled, 4); + assert!(result.is_some()); + let (cat_key, cat_name, _, _) = result.unwrap(); + assert_eq!(cat_key, "category_autre"); + assert_eq!(cat_name, "Divers"); + } } diff --git a/backend/tests/api_article_history_test.rs b/backend/tests/api_article_history_test.rs new file mode 100644 index 0000000..f2b816f --- /dev/null +++ b/backend/tests/api_article_history_test.rs @@ -0,0 +1,133 @@ +//! Integration tests for the article history and provenance endpoints. +//! +//! Tests: +//! - GET /api/v1/article-history — list article history +//! - DELETE /api/v1/article-history — clear article history +//! - GET /api/v1/syntheses/:id/provenance — get provenance for a synthesis +//! +//! Covers authentication, empty-state responses, and nonexistent lookups. +//! +//! 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() +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Authentication +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn list_article_history_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_with_session("/api/v1/article-history", "invalid-token") + .await; + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "GET /article-history without auth should return 401" + ); + assert_eq!(body["error"], "unauthorized"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// List — empty state +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn list_article_history_returns_empty_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("history-empty@example.com") + .await; + + let (status, resp) = app + .get_with_session("/api/v1/article-history", &session) + .await; + + assert_eq!( + status, + StatusCode::OK, + "GET /article-history should return 200" + ); + let items = resp["items"] + .as_array() + .expect("Response should contain an items array"); + assert!(items.is_empty(), "New user should have empty article history"); + assert_eq!(resp["total"], 0); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Clear history +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn clear_article_history_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("history-clear@example.com") + .await; + + let (status, resp) = app + .delete_with_session("/api/v1/article-history", &session) + .await; + + assert_eq!( + status, + StatusCode::OK, + "DELETE /article-history should return 200" + ); + assert_eq!(resp["deleted"], 0, "No entries to delete for a new user"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Provenance — nonexistent synthesis +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn get_provenance_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("provenance-404@example.com") + .await; + + let random_id = uuid::Uuid::new_v4(); + let (status, _resp) = app + .get_with_session( + &format!("/api/v1/syntheses/{}/provenance", random_id), + &session, + ) + .await; + + assert_eq!( + status, + StatusCode::NOT_FOUND, + "GET /syntheses/:id/provenance for nonexistent synthesis should return 404" + ); +} diff --git a/backend/tests/api_themes_test.rs b/backend/tests/api_themes_test.rs new file mode 100644 index 0000000..20e24ab --- /dev/null +++ b/backend/tests/api_themes_test.rs @@ -0,0 +1,376 @@ +//! Integration tests for the themes CRUD endpoints. +//! +//! Tests: +//! - GET /api/v1/themes — list user's themes +//! - POST /api/v1/themes — create a theme +//! - PUT /api/v1/themes/:id — update a theme +//! - DELETE /api/v1/themes/:id — delete a theme +//! +//! Covers authentication, validation, ownership isolation, and CRUD operations. +//! +//! 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() +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Authentication +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn list_themes_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_with_session("/api/v1/themes", "invalid-token") + .await; + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "GET /themes without auth should return 401" + ); + assert_eq!(body["error"], "unauthorized"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CRUD — List +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn list_themes_returns_created_theme() { + 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("themes-list@example.com") + .await; + + // Create a theme so the list is not empty + let body = serde_json::json!({ + "name": "Test Theme", + "theme": "Intelligence Artificielle", + "categories": ["AI News", "Research"] + }); + let (create_status, _) = app + .post_with_session("/api/v1/themes", &body, &session) + .await; + assert_eq!(create_status, StatusCode::CREATED); + + // List themes + let (status, resp) = app.get_with_session("/api/v1/themes", &session).await; + + assert_eq!(status, StatusCode::OK, "GET /themes should return 200"); + let themes = resp.as_array().expect("Response should be an array"); + assert!( + !themes.is_empty(), + "User should have at least 1 theme after creation" + ); + assert_eq!(themes[0]["name"], "Test Theme"); + assert_eq!(themes[0]["theme"], "Intelligence Artificielle"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CRUD — Create +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn create_theme_returns_201() { + 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("themes-create@example.com") + .await; + + let body = serde_json::json!({ + "name": "Test Theme", + "theme": "Intelligence Artificielle", + "categories": ["AI News", "Research"] + }); + let (status, resp) = app + .post_with_session("/api/v1/themes", &body, &session) + .await; + + assert_eq!( + status, + StatusCode::CREATED, + "POST /themes with valid data should return 201" + ); + assert!( + resp["id"].as_str().is_some(), + "Response should contain an id" + ); + assert_eq!(resp["name"], "Test Theme"); + let categories = resp["categories"] + .as_array() + .expect("categories should be an array"); + assert_eq!(categories.len(), 2); + assert_eq!(categories[0], "AI News"); + assert_eq!(categories[1], "Research"); +} + +#[tokio::test] +async fn create_theme_empty_name_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("themes-empty-name@example.com") + .await; + + let body = serde_json::json!({ + "name": "", + "theme": "Intelligence Artificielle", + "categories": ["AI News"] + }); + let (status, _resp) = app + .post_with_session("/api/v1/themes", &body, &session) + .await; + + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "POST /themes with empty name should return 422" + ); +} + +#[tokio::test] +async fn create_theme_empty_categories_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("themes-empty-cats@example.com") + .await; + + let body = serde_json::json!({ + "name": "Test Theme", + "theme": "Intelligence Artificielle", + "categories": [] + }); + let (status, _resp) = app + .post_with_session("/api/v1/themes", &body, &session) + .await; + + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "POST /themes with empty categories should return 422" + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CRUD — Update +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn update_theme_changes_name() { + 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("themes-update@example.com") + .await; + + // Create a theme + let body = serde_json::json!({ + "name": "Original Name", + "theme": "Intelligence Artificielle", + "categories": ["AI News"] + }); + let (status, created) = app + .post_with_session("/api/v1/themes", &body, &session) + .await; + assert_eq!(status, StatusCode::CREATED); + let theme_id = created["id"].as_str().unwrap(); + + // Update the name + let update_body = serde_json::json!({ + "name": "New Name" + }); + let (status, resp) = app + .put_with_session( + &format!("/api/v1/themes/{}", theme_id), + &update_body, + &session, + ) + .await; + + assert_eq!( + status, + StatusCode::OK, + "PUT /themes/:id should return 200" + ); + assert_eq!(resp["name"], "New Name"); + // Other fields should remain unchanged + assert_eq!(resp["theme"], "Intelligence Artificielle"); +} + +#[tokio::test] +async fn update_nonexistent_theme_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("themes-update-404@example.com") + .await; + + let random_id = uuid::Uuid::new_v4(); + let update_body = serde_json::json!({ + "name": "New Name" + }); + let (status, _resp) = app + .put_with_session( + &format!("/api/v1/themes/{}", random_id), + &update_body, + &session, + ) + .await; + + assert_eq!( + status, + StatusCode::NOT_FOUND, + "PUT /themes/:id with nonexistent id should return 404" + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CRUD — Delete +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn delete_theme_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("themes-delete@example.com") + .await; + + // Create a theme + let body = serde_json::json!({ + "name": "To Delete", + "theme": "Intelligence Artificielle", + "categories": ["AI News"] + }); + let (status, created) = app + .post_with_session("/api/v1/themes", &body, &session) + .await; + assert_eq!(status, StatusCode::CREATED); + let theme_id = created["id"].as_str().unwrap(); + + // Delete it + let (status, _resp) = app + .delete_with_session(&format!("/api/v1/themes/{}", theme_id), &session) + .await; + assert_eq!( + status, + StatusCode::NO_CONTENT, + "DELETE /themes/:id should return 204" + ); + + // Verify it's gone from the list + let (status, resp) = app.get_with_session("/api/v1/themes", &session).await; + assert_eq!(status, StatusCode::OK); + let themes = resp.as_array().expect("Response should be an array"); + let found = themes.iter().any(|t| t["id"].as_str() == Some(theme_id)); + assert!(!found, "Deleted theme should not appear in list"); +} + +#[tokio::test] +async fn delete_nonexistent_theme_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("themes-delete-404@example.com") + .await; + + let random_id = uuid::Uuid::new_v4(); + let (status, _resp) = app + .delete_with_session(&format!("/api/v1/themes/{}", random_id), &session) + .await; + + assert_eq!( + status, + StatusCode::NOT_FOUND, + "DELETE /themes/:id with nonexistent id should return 404" + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Ownership isolation +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn themes_are_isolated_per_user() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + + // Create user A with a theme + let (_user_a_id, session_a) = app + .create_authenticated_user("themes-user-a@example.com") + .await; + let body_a = serde_json::json!({ + "name": "User A Theme", + "theme": "Intelligence Artificielle", + "categories": ["AI News"] + }); + let (status, _) = app + .post_with_session("/api/v1/themes", &body_a, &session_a) + .await; + assert_eq!(status, StatusCode::CREATED); + + // Create user B + let (_user_b_id, session_b) = app + .create_authenticated_user("themes-user-b@example.com") + .await; + + // User B's theme list should be empty + let (status, resp) = app.get_with_session("/api/v1/themes", &session_b).await; + assert_eq!(status, StatusCode::OK); + let themes = resp.as_array().expect("Response should be an array"); + let has_user_a_theme = themes.iter().any(|t| t["name"] == "User A Theme"); + assert!( + !has_user_a_theme, + "User B should not see User A's themes" + ); +}