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