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.

622 lines
20 KiB
Rust

//! Integration tests for the settings endpoints.
//!
//! Tests GET and PUT /api/v1/settings, including authentication,
//! validation, defaults, and per-user isolation.
//!
//! 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()
}
// ── Auth requirement ─────────────────────────────────────────────────────
#[tokio::test]
async fn get_settings_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/settings", "invalid-session-token").await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"GET /settings without auth should return 401"
);
assert_eq!(body["error"], "unauthorized");
}
#[tokio::test]
async fn put_settings_without_auth_returns_401() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let body = serde_json::json!({
"theme": "Test",
"max_age_days": 7,
"categories": ["Cat"],
"max_items_per_category": 4,
"max_articles_per_source": 3,
"use_llm_for_source_links": false,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3,
"search_agent_behavior": "",
"ai_provider": "",
"ai_model": "",
"ai_model_websearch": "",
"rate_limit_max_requests": null,
"rate_limit_time_window_seconds": null
});
// PUT without a session cookie
let req = axum::http::Request::builder()
.method(axum::http::Method::PUT)
.uri("/api/v1/settings")
.header("Content-Type", "application/json")
.header("X-Requested-With", "XMLHttpRequest")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let response = app.raw_request(req).await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"PUT /settings without auth should return 401"
);
}
// ── Default settings ─────────────────────────────────────────────────────
#[tokio::test]
async fn get_settings_returns_defaults_on_first_access() {
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("settings-default@example.com")
.await;
let (status, body) = app.get_with_session("/api/v1/settings", &session).await;
assert_eq!(status, StatusCode::OK, "GET /settings should return 200");
assert_eq!(
body["theme"], "Intelligence Artificielle",
"Default theme should be 'Intelligence Artificielle'"
);
assert_eq!(
body["max_age_days"], 7,
"Default max_age_days should be 7"
);
assert_eq!(
body["max_items_per_category"], 4,
"Default max_items_per_category should be 4"
);
// Check default categories
let categories = body["categories"].as_array().expect("categories should be an array");
assert_eq!(categories.len(), 5, "Default should have 5 categories");
assert_eq!(categories[0], "Annonces majeures");
}
// ── Update settings ──────────────────────────────────────────────────────
#[tokio::test]
async fn put_settings_with_valid_data_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("settings-update@example.com")
.await;
let update = serde_json::json!({
"theme": "Cybersecurite",
"max_age_days": 14,
"categories": ["Vulnerabilites", "Patch Tuesday", "Threat Intel"],
"max_items_per_category": 6,
"max_articles_per_source": 3,
"use_llm_for_source_links": false,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3,
"search_agent_behavior": "Focus on CVEs",
"ai_provider": "",
"ai_model": "",
"ai_model_websearch": "",
"rate_limit_max_requests": null,
"rate_limit_time_window_seconds": null
});
let (status, body) = app
.put_with_session("/api/v1/settings", &update, &session)
.await;
assert_eq!(
status,
StatusCode::OK,
"PUT /settings with valid data should return 200"
);
assert_eq!(body["theme"], "Cybersecurite");
assert_eq!(body["max_age_days"], 14);
assert_eq!(body["max_items_per_category"], 6);
assert_eq!(body["search_agent_behavior"], "Focus on CVEs");
let categories = body["categories"].as_array().expect("categories array");
assert_eq!(categories.len(), 3);
assert_eq!(categories[0], "Vulnerabilites");
assert_eq!(categories[1], "Patch Tuesday");
assert_eq!(categories[2], "Threat Intel");
}
#[tokio::test]
async fn put_then_get_returns_updated_data() {
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("settings-roundtrip@example.com")
.await;
// First, trigger default creation
let (status, _) = app.get_with_session("/api/v1/settings", &session).await;
assert_eq!(status, StatusCode::OK);
// Update
let update = serde_json::json!({
"theme": "Economie",
"max_age_days": 30,
"categories": ["Macro", "Finance"],
"max_items_per_category": 10,
"max_articles_per_source": 3,
"use_llm_for_source_links": false,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3,
"search_agent_behavior": "Francophone sources",
"ai_provider": "",
"ai_model": "",
"ai_model_websearch": "",
"rate_limit_max_requests": null,
"rate_limit_time_window_seconds": null
});
let (put_status, _) = app
.put_with_session("/api/v1/settings", &update, &session)
.await;
assert_eq!(put_status, StatusCode::OK);
// GET again — should reflect the update
let (get_status, body) = app.get_with_session("/api/v1/settings", &session).await;
assert_eq!(get_status, StatusCode::OK);
assert_eq!(body["theme"], "Economie");
assert_eq!(body["max_age_days"], 30);
assert_eq!(body["max_items_per_category"], 10);
assert_eq!(body["search_agent_behavior"], "Francophone sources");
let categories = body["categories"].as_array().expect("categories array");
assert_eq!(categories.len(), 2);
assert_eq!(categories[0], "Macro");
assert_eq!(categories[1], "Finance");
}
// ── Validation errors ────────────────────────────────────────────────────
#[tokio::test]
async fn put_settings_empty_theme_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("settings-val-theme@example.com")
.await;
let update = serde_json::json!({
"theme": " ",
"max_age_days": 7,
"categories": ["Cat"],
"max_items_per_category": 4,
"max_articles_per_source": 3,
"use_llm_for_source_links": false,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3,
"search_agent_behavior": "",
"ai_provider": "",
"ai_model": "",
"ai_model_websearch": "",
"rate_limit_max_requests": null,
"rate_limit_time_window_seconds": null
});
let (status, body) = app
.put_with_session("/api/v1/settings", &update, &session)
.await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"Empty theme should return 422"
);
assert_eq!(body["error"], "validation_error");
}
#[tokio::test]
async fn put_settings_too_many_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("settings-val-cats@example.com")
.await;
let categories: Vec<String> = (0..21).map(|i| format!("Cat {}", i)).collect();
let update = serde_json::json!({
"theme": "AI",
"max_age_days": 7,
"categories": categories,
"max_items_per_category": 4,
"max_articles_per_source": 3,
"use_llm_for_source_links": false,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3,
"search_agent_behavior": "",
"ai_provider": "",
"ai_model": "",
"ai_model_websearch": "",
"rate_limit_max_requests": null,
"rate_limit_time_window_seconds": null
});
let (status, body) = app
.put_with_session("/api/v1/settings", &update, &session)
.await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"More than 20 categories should return 422"
);
assert_eq!(body["error"], "validation_error");
}
#[tokio::test]
async fn put_settings_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("settings-val-empty-cats@example.com")
.await;
let update = serde_json::json!({
"theme": "AI",
"max_age_days": 7,
"categories": [],
"max_items_per_category": 4,
"max_articles_per_source": 3,
"use_llm_for_source_links": false,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3,
"search_agent_behavior": "",
"ai_provider": "",
"ai_model": "",
"ai_model_websearch": "",
"rate_limit_max_requests": null,
"rate_limit_time_window_seconds": null
});
let (status, body) = app
.put_with_session("/api/v1/settings", &update, &session)
.await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"Empty categories array should return 422"
);
assert_eq!(body["error"], "validation_error");
}
#[tokio::test]
async fn put_settings_max_age_days_out_of_range_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("settings-val-age@example.com")
.await;
// Below range
let update = serde_json::json!({
"theme": "AI",
"max_age_days": 0,
"categories": ["Cat"],
"max_items_per_category": 4,
"max_articles_per_source": 3,
"use_llm_for_source_links": false,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3,
"search_agent_behavior": "",
"ai_provider": "",
"ai_model": "",
"ai_model_websearch": "",
"rate_limit_max_requests": null,
"rate_limit_time_window_seconds": null
});
let (status, _) = app
.put_with_session("/api/v1/settings", &update, &session)
.await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"max_age_days=0 should return 422"
);
// Above range
let update2 = serde_json::json!({
"theme": "AI",
"max_age_days": 366,
"categories": ["Cat"],
"max_items_per_category": 4,
"max_articles_per_source": 3,
"use_llm_for_source_links": false,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3,
"search_agent_behavior": "",
"ai_provider": "",
"ai_model": "",
"ai_model_websearch": "",
"rate_limit_max_requests": null,
"rate_limit_time_window_seconds": null
});
let (status2, _) = app
.put_with_session("/api/v1/settings", &update2, &session)
.await;
assert_eq!(
status2,
StatusCode::UNPROCESSABLE_ENTITY,
"max_age_days=366 should return 422"
);
}
#[tokio::test]
async fn put_settings_max_items_out_of_range_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("settings-val-items@example.com")
.await;
let update = serde_json::json!({
"theme": "AI",
"max_age_days": 7,
"categories": ["Cat"],
"max_items_per_category": 51,
"max_articles_per_source": 3,
"use_llm_for_source_links": false,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3,
"search_agent_behavior": "",
"ai_provider": "",
"ai_model": "",
"ai_model_websearch": "",
"rate_limit_max_requests": null,
"rate_limit_time_window_seconds": null
});
let (status, _) = app
.put_with_session("/api/v1/settings", &update, &session)
.await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"max_items_per_category=51 should return 422"
);
}
// ── Per-user isolation ───────────────────────────────────────────────────
#[tokio::test]
async fn settings_are_per_user_isolated() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
// Create two users with sessions
let (_user_a_id, session_a) = app
.create_authenticated_user("user-a-settings@example.com")
.await;
let (_user_b_id, session_b) = app
.create_authenticated_user("user-b-settings@example.com")
.await;
// User A updates their settings
let update_a = serde_json::json!({
"theme": "User A Theme",
"max_age_days": 3,
"categories": ["A-Category"],
"max_items_per_category": 2,
"max_articles_per_source": 3,
"use_llm_for_source_links": false,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3,
"search_agent_behavior": "User A behavior",
"ai_provider": "",
"ai_model": "",
"ai_model_websearch": "",
"rate_limit_max_requests": null,
"rate_limit_time_window_seconds": null
});
let (status_a, _) = app
.put_with_session("/api/v1/settings", &update_a, &session_a)
.await;
assert_eq!(status_a, StatusCode::OK);
// User B updates their settings differently
let update_b = serde_json::json!({
"theme": "User B Theme",
"max_age_days": 14,
"categories": ["B-Category-1", "B-Category-2"],
"max_items_per_category": 8,
"max_articles_per_source": 3,
"use_llm_for_source_links": false,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3,
"search_agent_behavior": "User B behavior",
"ai_provider": "",
"ai_model": "",
"ai_model_websearch": "",
"rate_limit_max_requests": null,
"rate_limit_time_window_seconds": null
});
let (status_b, _) = app
.put_with_session("/api/v1/settings", &update_b, &session_b)
.await;
assert_eq!(status_b, StatusCode::OK);
// Verify User A sees only their settings
let (_, body_a) = app.get_with_session("/api/v1/settings", &session_a).await;
assert_eq!(body_a["theme"], "User A Theme");
assert_eq!(body_a["max_age_days"], 3);
let cats_a = body_a["categories"].as_array().unwrap();
assert_eq!(cats_a.len(), 1);
assert_eq!(cats_a[0], "A-Category");
// Verify User B sees only their settings
let (_, body_b) = app.get_with_session("/api/v1/settings", &session_b).await;
assert_eq!(body_b["theme"], "User B Theme");
assert_eq!(body_b["max_age_days"], 14);
let cats_b = body_b["categories"].as_array().unwrap();
assert_eq!(cats_b.len(), 2);
assert_eq!(cats_b[0], "B-Category-1");
}
// ── Boundary values ─────────────────────────────────────────────────────
#[tokio::test]
async fn put_settings_boundary_values_succeed() {
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("settings-boundary@example.com")
.await;
// Minimum valid values
let update_min = serde_json::json!({
"theme": "A",
"max_age_days": 1,
"categories": ["C"],
"max_items_per_category": 1,
"max_articles_per_source": 3,
"use_llm_for_source_links": false,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 1,
"source_extraction_window": 1,
"search_agent_behavior": "",
"ai_provider": "",
"ai_model": "",
"ai_model_websearch": "",
"rate_limit_max_requests": null,
"rate_limit_time_window_seconds": null
});
let (status, _) = app
.put_with_session("/api/v1/settings", &update_min, &session)
.await;
assert_eq!(status, StatusCode::OK, "Minimum boundary values should be accepted");
// Maximum valid values
let categories_max: Vec<String> = (0..20).map(|i| format!("Cat {}", i)).collect();
let update_max = serde_json::json!({
"theme": "a".repeat(200),
"max_age_days": 365,
"categories": categories_max,
"max_items_per_category": 50,
"max_articles_per_source": 3,
"use_llm_for_source_links": false,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 3,
"source_extraction_window": 10,
"search_agent_behavior": "a".repeat(2000),
"ai_provider": "",
"ai_model": "",
"ai_model_websearch": "",
"rate_limit_max_requests": null,
"rate_limit_time_window_seconds": null
});
let (status2, _) = app
.put_with_session("/api/v1/settings", &update_max, &session)
.await;
assert_eq!(status2, StatusCode::OK, "Maximum boundary values should be accepted");
}