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) <noreply@anthropic.com>
master
oabrivard 3 months ago
parent d2f98dc66f
commit 14908cf603

@ -1848,4 +1848,92 @@ mod tests {
let result = sanitize_error_message(&msg); let result = sanitize_error_message(&msg);
assert!(result.ends_with("...")); 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");
}
} }

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

@ -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"
);
}
Loading…
Cancel
Save