//! Integration tests for the admin crawler observability/control API. //! //! The default test harness wires `AppState.crawler = None` (no daemon), //! so the *control* endpoints return 503 and the *read* endpoints that //! work off the DB (status shell, dead-jobs list/requeue) still function. //! This is exactly the production "daemon disabled" posture. mod common; use axum::http::StatusCode; use axum::Router; use serde_json::json; use sqlx::PgPool; use tower::ServiceExt; use uuid::Uuid; use common::{body_json, get, get_with_cookie, post_json_with_cookie, register_user, harness}; 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 } async fn seed_dead_job(pool: &PgPool, title: &str) -> Uuid { let manga_id = Uuid::new_v4(); let chapter_id = Uuid::new_v4(); sqlx::query("INSERT INTO mangas (id, title) VALUES ($1, $2)") .bind(manga_id) .bind(title) .execute(pool) .await .unwrap(); sqlx::query("INSERT INTO chapters (id, manga_id, number) VALUES ($1, $2, 1)") .bind(chapter_id) .bind(manga_id) .execute(pool) .await .unwrap(); let job_id = Uuid::new_v4(); sqlx::query( "INSERT INTO crawler_jobs (id, payload, state, attempts, last_error) \ VALUES ($1, $2, 'dead', 5, 'boom')", ) .bind(job_id) .bind(json!({ "kind": "sync_chapter_content", "source_id": "target", "chapter_id": chapter_id, "source_chapter_key": "k", })) .execute(pool) .await .unwrap(); job_id } #[sqlx::test(migrations = "./migrations")] async fn get_status_requires_admin(pool: PgPool) { let h = harness(pool); // Unauthenticated → 401. let resp = h.app.clone().oneshot(get("/api/v1/admin/crawler")).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); // Authenticated non-admin → 403. let (_u, cookie) = register_user(&h.app).await; let resp = h .app .clone() .oneshot(get_with_cookie("/api/v1/admin/crawler", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[sqlx::test(migrations = "./migrations")] async fn get_status_reports_disabled_daemon_with_queue_counts(pool: PgPool) { seed_dead_job(&pool, "Naruto").await; let h = harness(pool.clone()); let cookie = seed_admin(&pool, &h.app).await; let resp = h .app .clone() .oneshot(get_with_cookie("/api/v1/admin/crawler", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert_eq!(body["daemon"], "disabled"); assert_eq!(body["queue"]["dead"], 1); assert_eq!(body["browser"], "down"); } #[sqlx::test(migrations = "./migrations")] async fn control_endpoints_return_503_when_daemon_disabled(pool: PgPool) { let h = harness(pool.clone()); let cookie = seed_admin(&pool, &h.app).await; for uri in [ "/api/v1/admin/crawler/run", "/api/v1/admin/crawler/browser/restart", "/api/v1/admin/crawler/session/clear-expired", ] { let resp = h .app .clone() .oneshot(post_json_with_cookie(uri, json!({}), &cookie)) .await .unwrap(); assert_eq!( resp.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri} should be 503 when daemon disabled" ); } } #[sqlx::test(migrations = "./migrations")] async fn dead_jobs_list_and_requeue_over_http(pool: PgPool) { let job_id = seed_dead_job(&pool, "Bleach").await; let h = harness(pool.clone()); let cookie = seed_admin(&pool, &h.app).await; // List. let resp = h .app .clone() .oneshot(get_with_cookie("/api/v1/admin/crawler/dead-jobs", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert_eq!(body["page"]["total"], 1); assert_eq!(body["items"][0]["manga_title"], "Bleach"); // Requeue the single job. let resp = h .app .clone() .oneshot(post_json_with_cookie( "/api/v1/admin/crawler/dead-jobs/requeue", json!({ "scope": "job", "job_id": job_id }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert_eq!(body["requeued"], 1); let state: String = sqlx::query_scalar("SELECT state FROM crawler_jobs WHERE id = $1") .bind(job_id) .fetch_one(&pool) .await .unwrap(); assert_eq!(state, "pending"); }