GET /admin/crawler live status (phase, workers,
last pass, session, browser, queue)
POST /admin/crawler/run trigger an out-of-cycle metadata pass
POST /admin/crawler/browser/restart coordinated Chromium restart
POST /admin/crawler/session refresh PHPSESSID + re-probe
POST /admin/crawler/session/clear-expired clear the sticky expired flag
GET /admin/crawler/dead-jobs paginated dead-letter list
POST /admin/crawler/dead-jobs/requeue requeue all / per-manga / single
All cookie-only via RequireAdmin; control endpoints 503 when the daemon is
disabled; mutations are audit-logged. Reads compose the live status with
DB-derived queue counts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
164 lines
4.9 KiB
Rust
164 lines
4.9 KiB
Rust
//! 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");
|
|
}
|