//! Integration tests for the runtime-editable settings endpoints //! (`/api/v1/admin/settings/{crawler,analysis}`): //! //! * the `RequireAdmin` gate, //! * `GET` returns the editable DTO + the read-only env-managed view (and the //! analysis prompt defaults), never the secret, //! * `PUT` validates (422 with per-field details on bad input), persists, and //! writes an `admin_audit` row, //! * `PUT` invokes the daemon reloader with the converted config and moves the //! analysis enable gate, //! * the repo-level env→DB seed is idempotent. mod common; use axum::http::StatusCode; use axum::Router; use serde_json::json; use sqlx::PgPool; use tower::ServiceExt; use uuid::Uuid; use mangalord::repo; async fn seed_admin(pool: &PgPool, app: &Router) -> (String, String, Uuid) { let (username, cookie) = common::register_user(app).await; let u = repo::user::find_by_username(pool, &username) .await .unwrap() .unwrap(); repo::user::set_is_admin_unchecked(pool, u.id, true).await.unwrap(); (username, cookie, u.id) } // ---- RequireAdmin gate ----------------------------------------------------- #[sqlx::test(migrations = "./migrations")] async fn get_settings_requires_admin(pool: PgPool) { let h = common::harness(pool); let (_u, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::get_with_cookie("/api/v1/admin/settings/crawler", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[sqlx::test(migrations = "./migrations")] async fn get_settings_rejects_anonymous(pool: PgPool) { let h = common::harness(pool); // No cookie at all → not logged in. let resp = h .app .oneshot(common::get("/api/v1/admin/settings/crawler")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "./migrations")] async fn put_settings_requires_admin(pool: PgPool) { let h = common::harness(pool); let (_u, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::put_json_with_cookie( "/api/v1/admin/settings/analysis", json!({ "enabled": false }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } // ---- GET shape ------------------------------------------------------------- #[sqlx::test(migrations = "./migrations")] async fn get_crawler_returns_editable_and_env_only(pool: PgPool) { let h = common::harness(pool.clone()); let (_u, cookie, _id) = seed_admin(&pool, &h.app).await; let resp = h .app .oneshot(common::get_with_cookie("/api/v1/admin/settings/crawler", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; // Editable knobs present with their defaults. assert_eq!(body["editable"]["daily_at"], "00:00"); assert_eq!(body["editable"]["chapter_workers"], 1); // Env-managed view present and read-only. assert_eq!(body["env_only"]["browser_mode"], "headless"); assert_eq!(body["env_only"]["session_configured"], false); } #[sqlx::test(migrations = "./migrations")] async fn get_analysis_returns_defaults_and_no_secret(pool: PgPool) { let h = common::harness(pool.clone()); let (_u, cookie, _id) = seed_admin(&pool, &h.app).await; let resp = h .app .oneshot(common::get_with_cookie("/api/v1/admin/settings/analysis", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; // Unmodified prompts seed as null ("use default"). assert!(body["editable"]["system_prompt"].is_null()); // Compiled defaults exposed for the UI placeholder / reset. assert!(body["prompt_defaults"]["system_prompt"] .as_str() .unwrap() .contains("manga")); // The secret is never echoed; only a boolean indicator. assert!(body["editable"].get("api_key").is_none()); assert_eq!(body["env_only"]["api_key_configured"], false); } // ---- PUT validation -------------------------------------------------------- #[sqlx::test(migrations = "./migrations")] async fn put_crawler_invalid_tz_returns_422(pool: PgPool) { let h = common::harness(pool.clone()); let (_u, cookie, _id) = seed_admin(&pool, &h.app).await; let resp = h .app .oneshot(common::put_json_with_cookie( "/api/v1/admin/settings/crawler", json!({ "tz": "Mars/Phobos", "daily_at": "9am" }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = common::body_json(resp).await; let fields: Vec<&str> = body["error"]["details"]["fields"] .as_array() .unwrap() .iter() .map(|e| e["field"].as_str().unwrap()) .collect(); assert!(fields.contains(&"tz")); assert!(fields.contains(&"daily_at")); } #[sqlx::test(migrations = "./migrations")] async fn put_analysis_invalid_returns_422(pool: PgPool) { let h = common::harness(pool.clone()); let (_u, cookie, _id) = seed_admin(&pool, &h.app).await; let resp = h .app .oneshot(common::put_json_with_cookie( "/api/v1/admin/settings/analysis", json!({ "workers": 0, "slice_overlap": 2.0, "endpoint": "nope" }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = common::body_json(resp).await; let fields: Vec<&str> = body["error"]["details"]["fields"] .as_array() .unwrap() .iter() .map(|e| e["field"].as_str().unwrap()) .collect(); assert!(fields.contains(&"workers")); assert!(fields.contains(&"slice_overlap")); assert!(fields.contains(&"endpoint")); } // ---- PUT persistence + audit ---------------------------------------------- #[sqlx::test(migrations = "./migrations")] async fn put_crawler_persists_and_audits(pool: PgPool) { let h = common::harness(pool.clone()); let (_u, cookie, admin_id) = seed_admin(&pool, &h.app).await; let resp = h .app .clone() .oneshot(common::put_json_with_cookie( "/api/v1/admin/settings/crawler", json!({ "rate_ms": 2500, "chapter_workers": 4, "start_url": "https://example.com/" }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["editable"]["rate_ms"], 2500); assert_eq!(body["editable"]["chapter_workers"], 4); // Allowlist normalized to include the start-url host. let allow: Vec<&str> = body["editable"]["download_allowlist"] .as_array() .unwrap() .iter() .map(|v| v.as_str().unwrap()) .collect(); assert!(allow.contains(&"example.com")); // A second GET reflects the persisted change. let resp = h .app .clone() .oneshot(common::get_with_cookie("/api/v1/admin/settings/crawler", &cookie)) .await .unwrap(); let body = common::body_json(resp).await; assert_eq!(body["editable"]["rate_ms"], 2500); // An audit row landed. let (action, kind): (String, String) = sqlx::query_as( "SELECT action, target_kind FROM admin_audit WHERE actor_user_id = $1 ORDER BY at DESC LIMIT 1", ) .bind(admin_id) .fetch_one(&pool) .await .unwrap(); assert_eq!(action, "update_crawler_settings"); assert_eq!(kind, "settings"); } // ---- PUT triggers reload --------------------------------------------------- #[sqlx::test(migrations = "./migrations")] async fn put_analysis_triggers_reload_and_flips_gate(pool: PgPool) { let (h, reloader) = common::harness_with_settings_reloader(pool.clone()); let (_u, cookie, _id) = seed_admin(&pool, &h.app).await; // Gate starts closed. assert!(!reloader.runtime.analysis_enabled()); let resp = h .app .clone() .oneshot(common::put_json_with_cookie( "/api/v1/admin/settings/analysis", // Endpoint must pass `ensure_public_target` whenever // `enabled=true`. Use the documented docker-internal DNS // name; a literal IP/localhost would (rightly) 422. json!({ "enabled": true, "endpoint": "http://mangalord-vision:8000/v1/chat/completions", "model": "qwen2-vl", "temperature": 0.4, }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); // The reloader was invoked with the converted config, and the shared gate // flipped on — both without a restart. assert!(reloader.runtime.analysis_enabled()); let applied = reloader.analysis.lock().unwrap().clone().expect("reload called"); assert!(applied.enabled); assert_eq!(applied.model, "qwen2-vl"); assert_eq!(applied.temperature, 0.4); } // ---- repo-level seed ------------------------------------------------------- #[sqlx::test(migrations = "./migrations")] async fn seed_if_absent_is_idempotent(pool: PgPool) { let v1 = json!({ "rate_ms": 1000 }); let v2 = json!({ "rate_ms": 9999 }); // First seed inserts. assert!(repo::app_settings::seed_if_absent(&pool, "crawler", &v1).await.unwrap()); // Second seed is a no-op (row already present) and does not overwrite. assert!(!repo::app_settings::seed_if_absent(&pool, "crawler", &v2).await.unwrap()); let got = repo::app_settings::get(&pool, "crawler").await.unwrap().unwrap(); assert_eq!(got["rate_ms"], 1000); // upsert does overwrite. repo::app_settings::upsert(&pool, "crawler", &v2).await.unwrap(); let got = repo::app_settings::get(&pool, "crawler").await.unwrap().unwrap(); assert_eq!(got["rate_ms"], 9999); }