//! 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 = (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 = (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"); }