//! Integration tests for the admin health endpoints: `GET /v1/admin/health` //! (shape, admin-gating, default checks present without a daemon) and //! `PUT /v1/admin/health/thresholds` (validation, persistence, audit row). mod common; use axum::http::StatusCode; use axum::Router; use serde_json::json; use sqlx::PgPool; use tower::ServiceExt; use common::{body_json, get_with_cookie, harness, put_json_with_cookie, register_user}; async fn seed_admin(pool: &PgPool, app: &Router) -> String { let (username, cookie) = register_user(app).await; let u = mangalord::repo::user::find_by_username(pool, &username) .await .unwrap() .unwrap(); mangalord::repo::user::set_is_admin_unchecked(pool, u.id, true) .await .unwrap(); cookie } /// PUT helper (the common module exposes POST helpers; build a PUT here). fn valid_thresholds() -> serde_json::Value { json!({ "disk_pct": 85.0, "mem_pct": 80.0, "dead_jobs_max": 50, "crawl_fail_pct": 25.0, "analysis_fail_pct": 30.0, "cron_freshness_hours": 12, "missing_covers_max": 200 }) } #[sqlx::test(migrations = "./migrations")] async fn requires_admin(pool: PgPool) { let h = harness(pool.clone()); let (_u, cookie) = register_user(&h.app).await; let resp = h .app .oneshot(get_with_cookie("/api/v1/admin/health", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[sqlx::test(migrations = "./migrations")] async fn report_has_status_checks_and_default_thresholds(pool: PgPool) { let h = harness(pool.clone()); let cookie = seed_admin(&pool, &h.app).await; let resp = h .app .oneshot(get_with_cookie("/api/v1/admin/health", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; // Overall status + a non-empty checks array (memory / dead_jobs / // missing_covers / fail-rates are always present, daemon or not). assert!(body["status"].is_string()); let checks = body["checks"].as_array().unwrap(); assert!(!checks.is_empty()); let ids: Vec<&str> = checks.iter().map(|c| c["id"].as_str().unwrap()).collect(); assert!(ids.contains(&"memory")); assert!(ids.contains(&"dead_jobs")); assert!(ids.contains(&"crawl_fail_rate")); // The harness wires no crawler daemon, so session/browser checks are absent. assert!(!ids.contains(&"crawler_session")); // Default thresholds echoed back. assert_eq!(body["thresholds"]["dead_jobs_max"], 100); assert_eq!(body["thresholds"]["disk_pct"], 90.0); } #[sqlx::test(migrations = "./migrations")] async fn dead_jobs_check_warns_when_over_threshold(pool: PgPool) { let h = harness(pool.clone()); let cookie = seed_admin(&pool, &h.app).await; // Lower the threshold to 0, then a single dead job trips the check. let mut t = valid_thresholds(); t["dead_jobs_max"] = json!(0); let resp = h .app .clone() .oneshot(put_json_with_cookie( "/api/v1/admin/health/thresholds", t, &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); sqlx::query( "INSERT INTO crawler_jobs (payload, state) VALUES ($1, 'dead')", ) .bind(json!({ "kind": "sync_manga", "source_id": "target", "source_manga_key": "k", "url": "http://x", "title": "T" })) .execute(&pool) .await .unwrap(); let resp = h .app .clone() .oneshot(get_with_cookie("/api/v1/admin/health", &cookie)) .await .unwrap(); let body = body_json(resp).await; let dead = body["checks"] .as_array() .unwrap() .iter() .find(|c| c["id"] == "dead_jobs") .unwrap(); assert_eq!(dead["status"], "warn"); assert_eq!(body["status"], "warn"); // overall rolls up the worst } #[sqlx::test(migrations = "./migrations")] async fn update_thresholds_persists_and_audits(pool: PgPool) { let h = harness(pool.clone()); let cookie = seed_admin(&pool, &h.app).await; let resp = h .app .clone() .oneshot(put_json_with_cookie( "/api/v1/admin/health/thresholds", valid_thresholds(), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; // Response re-evaluates against the new thresholds. assert_eq!(body["thresholds"]["dead_jobs_max"], 50); // Persisted to app_settings. let stored: serde_json::Value = sqlx::query_scalar("SELECT value FROM app_settings WHERE key = 'health_thresholds'") .fetch_one(&pool) .await .unwrap(); assert_eq!(stored["mem_pct"], 80.0); // Audit row written. let action: String = sqlx::query_scalar("SELECT action FROM admin_audit ORDER BY at DESC LIMIT 1") .fetch_one(&pool) .await .unwrap(); assert_eq!(action, "update_health_thresholds"); } #[sqlx::test(migrations = "./migrations")] async fn last_metadata_tick_at_reads_crawler_state(pool: PgPool) { // Absent → None. assert!(mangalord::repo::crawler::last_metadata_tick_at(&pool) .await .unwrap() .is_none()); // Stored in the daemon's `{"at": rfc3339}` shape → parsed back. sqlx::query( "INSERT INTO crawler_state (key, value) VALUES ('last_metadata_tick_at', $1)", ) .bind(json!({ "at": "2026-06-18T09:30:00Z" })) .execute(&pool) .await .unwrap(); let got = mangalord::repo::crawler::last_metadata_tick_at(&pool) .await .unwrap() .unwrap(); assert_eq!(got.to_rfc3339(), "2026-06-18T09:30:00+00:00"); } #[sqlx::test(migrations = "./migrations")] async fn update_thresholds_rejects_out_of_range(pool: PgPool) { let h = harness(pool.clone()); let cookie = seed_admin(&pool, &h.app).await; let mut bad = valid_thresholds(); bad["disk_pct"] = json!(150.0); // > 100 let resp = h .app .oneshot(put_json_with_cookie( "/api/v1/admin/health/thresholds", bad, &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); }