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
parent
d2f98dc66f
commit
14908cf603
@ -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…
Reference in New Issue