Replace the dashboard's 5s polling with a Server-Sent Events stream: - StatusHandle gains a tokio `watch` version bumped on every mutation; GET /admin/crawler/stream subscribes and pushes a composed snapshot immediately on connect, then on every status change (instant, no lost-wakeup) plus a 5s backstop for DB queue counts / browser phase. - Non-status signals poke the notifier so they push immediately too: session-expired (worker), session update / clear-expired / browser restart (endpoints). - compose_status is shared by the one-shot GET and the stream; the stream tolerates transient DB errors with a keep-alive comment instead of tearing down. Frontend: the crawler page opens an EventSource on mount and closes it on destroy, so the subscription is scoped to the active page (no global subscription). A one-shot fetch still paints initial state / serves as a fallback if SSE is blocked; a live/reconnecting indicator reflects the connection. The existing reverse proxy already streams SSE (its abort timer is cleared once response headers arrive), so no proxy change needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
249 lines
7.6 KiB
Rust
249 lines
7.6 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 std::time::Duration;
|
|
|
|
use axum::http::StatusCode;
|
|
use axum::Router;
|
|
use http_body_util::BodyExt;
|
|
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 status_stream_requires_admin(pool: PgPool) {
|
|
let h = harness(pool);
|
|
// Unauthenticated → 401.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(get("/api/v1/admin/crawler/stream"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
// Non-admin → 403.
|
|
let (_u, cookie) = register_user(&h.app).await;
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(get_with_cookie("/api/v1/admin/crawler/stream", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn status_stream_emits_initial_event(pool: PgPool) {
|
|
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/stream", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let ct = resp
|
|
.headers()
|
|
.get(axum::http::header::CONTENT_TYPE)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
assert!(ct.starts_with("text/event-stream"), "content-type was {ct:?}");
|
|
|
|
// Accumulate frames (the immediate snapshot may arrive split across
|
|
// frames) until the status payload appears, with an overall timeout so
|
|
// the never-ending stream can't hang the test.
|
|
let mut body = resp.into_body();
|
|
let mut acc = String::new();
|
|
let deadline = tokio::time::timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
let Some(frame) = body.frame().await else { break };
|
|
if let Ok(data) = frame.expect("frame ok").into_data() {
|
|
acc.push_str(&String::from_utf8_lossy(&data));
|
|
if acc.contains("\"daemon\"") {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
assert!(deadline.is_ok(), "did not receive status within 5s; got: {acc:?}");
|
|
assert!(acc.contains("\"daemon\""), "missing status payload: {acc}");
|
|
assert!(acc.contains("status"), "missing SSE event name: {acc}");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn mutating_endpoints_reject_non_admin(pool: PgPool) {
|
|
let h = harness(pool);
|
|
// A logged-in non-admin must be forbidden from a mutating endpoint.
|
|
let (_u, cookie) = register_user(&h.app).await;
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(post_json_with_cookie(
|
|
"/api/v1/admin/crawler/dead-jobs/requeue",
|
|
json!({ "scope": "all" }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[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");
|
|
}
|