Four 0.87.6 follow-ups from the adversarial review: 1. **Migration 0031 pre-dedup.** Demote all-but-lowest-id duplicate `analyze_page` rows in `(pending|running)` to `dead` before creating the unique index, with a curator-recoverable last_error marker. Without this, `sqlx::migrate!` would refuse to boot on any dirty production DB. 2. **`enqueue_for_page(force=true)` collision.** The partial unique index used to silently swallow force requests when a `force=false` job was already pending. Repo function now upgrades the pending row's `force` flag in place (or falls through to re-INSERT if the sibling drained mid-call), and reports an `EnqueueForPageOutcome` for accurate auditing. 3. **`record_duration` gate test.** New test seeds a `done` row with known duration, force-re-analyzes with failing dispatcher + max_attempts=3 (non-terminal), asserts duration_ms wasn't overwritten. 4. **`bookmark.rs` PK comment correction.** Use `b.id DESC` instead of the wrong `manga_id`; PK is actually `id`, and migration 0004 allows both chapter-level and manga-level bookmarks on the same manga. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
928 lines
30 KiB
Rust
928 lines
30 KiB
Rust
//! Integration tests for the AI-analysis enqueue wiring: chapter upload
|
|
//! enqueues `analyze_page` jobs, and the admin backfill / force-reanalyze
|
|
//! endpoints. All gated on `analysis_enabled` (the `harness_with_analysis`
|
|
//! variant turns it on; the default harness leaves it off → 503).
|
|
|
|
mod common;
|
|
|
|
use axum::http::StatusCode;
|
|
use axum::Router;
|
|
use serde_json::json;
|
|
use sqlx::PgPool;
|
|
use tower::ServiceExt;
|
|
|
|
use mangalord::repo;
|
|
|
|
async fn seed_admin(pool: &PgPool, app: &Router) -> String {
|
|
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();
|
|
cookie
|
|
}
|
|
|
|
/// Seed a manga → chapter with `n` pages, returning (manga_id, chapter_id,
|
|
/// page_ids).
|
|
async fn seed_manga_chapter_pages(
|
|
pool: &PgPool,
|
|
n: i32,
|
|
) -> (uuid::Uuid, uuid::Uuid, Vec<uuid::Uuid>) {
|
|
let manga_id: uuid::Uuid =
|
|
sqlx::query_scalar("INSERT INTO mangas (title) VALUES ('M') RETURNING id")
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap();
|
|
let chapter_id: uuid::Uuid = sqlx::query_scalar(
|
|
"INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id",
|
|
)
|
|
.bind(manga_id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap();
|
|
let mut ids = Vec::new();
|
|
for i in 1..=n {
|
|
let id: uuid::Uuid = sqlx::query_scalar(
|
|
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
|
|
VALUES ($1, $2, $3, 'image/png') RETURNING id",
|
|
)
|
|
.bind(chapter_id)
|
|
.bind(i)
|
|
.bind(format!("k/{manga_id}/{i}.png"))
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap();
|
|
ids.push(id);
|
|
}
|
|
(manga_id, chapter_id, ids)
|
|
}
|
|
|
|
/// Convenience wrapper for tests that only need the page ids.
|
|
async fn seed_pages(pool: &PgPool, n: i32) -> Vec<uuid::Uuid> {
|
|
seed_manga_chapter_pages(pool, n).await.2
|
|
}
|
|
|
|
async fn analyze_job_count(pool: &PgPool) -> i64 {
|
|
sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
|
|
)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn chapter_upload_enqueues_one_analysis_job_per_page(pool: PgPool) {
|
|
let h = common::harness_with_analysis(pool.clone());
|
|
let (_user, cookie) = common::register_user(&h.app).await;
|
|
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Uploaded").await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_multipart_with_cookie(
|
|
&format!("/api/v1/mangas/{manga_id}/chapters"),
|
|
common::MultipartBuilder::new()
|
|
.add_json("metadata", json!({ "number": 1, "title": "Ch 1" }))
|
|
.add_file("page", "p1.png", "image/png", &common::fake_png_bytes())
|
|
.add_file("page", "p2.png", "image/png", &common::fake_png_bytes()),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
|
|
assert_eq!(analyze_job_count(&pool).await, 2);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn chapter_upload_does_not_enqueue_when_analysis_disabled(pool: PgPool) {
|
|
// Default harness has analysis_enabled = false.
|
|
let h = common::harness(pool.clone());
|
|
let (_user, cookie) = common::register_user(&h.app).await;
|
|
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Uploaded").await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_multipart_with_cookie(
|
|
&format!("/api/v1/mangas/{manga_id}/chapters"),
|
|
common::MultipartBuilder::new()
|
|
.add_json("metadata", json!({ "number": 1 }))
|
|
.add_file("page", "p1.png", "image/png", &common::fake_png_bytes()),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
assert_eq!(analyze_job_count(&pool).await, 0);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn reenqueue_is_forbidden_for_non_admin(pool: PgPool) {
|
|
let h = common::harness_with_analysis(pool.clone());
|
|
let (_user, cookie) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/analysis/reenqueue",
|
|
json!({}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn reenqueue_returns_503_when_analysis_disabled(pool: PgPool) {
|
|
let h = common::harness(pool.clone()); // analysis disabled
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/analysis/reenqueue",
|
|
json!({}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn reenqueue_backfills_existing_pages(pool: PgPool) {
|
|
let h = common::harness_with_analysis(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
seed_pages(&pool, 3).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/analysis/reenqueue",
|
|
json!({ "only_unanalyzed": true }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["enqueued"], 3);
|
|
assert_eq!(analyze_job_count(&pool).await, 3);
|
|
|
|
// Idempotent: a second call enqueues nothing (pending jobs already exist).
|
|
let resp2 = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/analysis/reenqueue",
|
|
json!({ "only_unanalyzed": true }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let body2 = common::body_json(resp2).await;
|
|
assert_eq!(body2["enqueued"], 0);
|
|
assert_eq!(analyze_job_count(&pool).await, 3);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn reenqueue_scoped_to_manga(pool: PgPool) {
|
|
let h = common::harness_with_analysis(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
let (manga_a, _ch_a, _pa) = seed_manga_chapter_pages(&pool, 2).await;
|
|
seed_manga_chapter_pages(&pool, 3).await; // a different manga
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/analysis/reenqueue",
|
|
json!({ "manga_id": manga_a }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["enqueued"], 2, "only manga_a's pages enqueued");
|
|
assert_eq!(analyze_job_count(&pool).await, 2);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn reenqueue_scoped_to_chapter(pool: PgPool) {
|
|
let h = common::harness_with_analysis(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
let (_m, chapter_id, _ids) = seed_manga_chapter_pages(&pool, 4).await;
|
|
seed_manga_chapter_pages(&pool, 5).await; // unrelated chapter
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/analysis/reenqueue",
|
|
json!({ "chapter_id": chapter_id }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["enqueued"], 4);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn reenqueue_including_analyzed_sets_force_and_covers_done_pages(pool: PgPool) {
|
|
use mangalord::domain::page_analysis::{SafetyFlag, VisionAnalysis};
|
|
|
|
let h = common::harness_with_analysis(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
let ids = seed_pages(&pool, 2).await;
|
|
// First page already analyzed.
|
|
repo::page_analysis::persist_analysis(
|
|
&pool,
|
|
ids[0],
|
|
&VisionAnalysis {
|
|
ocr_results: vec![],
|
|
tagging_results: vec![],
|
|
scene_description: String::new(),
|
|
safety_flag: SafetyFlag::default(),
|
|
},
|
|
"m",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// only_unanalyzed=false → include the done page, with force=true.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/analysis/reenqueue",
|
|
json!({ "only_unanalyzed": false }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["enqueued"], 2, "both pages enqueued (incl. the done one)");
|
|
|
|
// The job for the already-done page must carry force=true so the worker
|
|
// re-analyzes instead of skipping it.
|
|
let force: bool = sqlx::query_scalar(
|
|
"SELECT (payload->>'force')::boolean FROM crawler_jobs \
|
|
WHERE payload->>'page_id' = $1",
|
|
)
|
|
.bind(ids[0].to_string())
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(force, "include-analyzed jobs must force re-analysis");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn reenqueue_manga_and_chapter_are_mutually_exclusive(pool: PgPool) {
|
|
let h = common::harness_with_analysis(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/analysis/reenqueue",
|
|
json!({ "manga_id": uuid::Uuid::new_v4(), "chapter_id": uuid::Uuid::new_v4() }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn reenqueue_unknown_scope_target_is_404(pool: PgPool) {
|
|
let h = common::harness_with_analysis(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
|
|
for body in [
|
|
json!({ "manga_id": uuid::Uuid::new_v4() }),
|
|
json!({ "chapter_id": uuid::Uuid::new_v4() }),
|
|
] {
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/analysis/reenqueue",
|
|
body,
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn reenqueue_only_unanalyzed_skips_done_pages(pool: PgPool) {
|
|
use mangalord::domain::page_analysis::{SafetyFlag, VisionAnalysis};
|
|
|
|
let h = common::harness_with_analysis(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
let ids = seed_pages(&pool, 2).await;
|
|
|
|
// Mark the first page as already analyzed.
|
|
repo::page_analysis::persist_analysis(
|
|
&pool,
|
|
ids[0],
|
|
&VisionAnalysis {
|
|
ocr_results: vec![],
|
|
tagging_results: vec![],
|
|
scene_description: String::new(),
|
|
safety_flag: SafetyFlag::default(),
|
|
},
|
|
"m",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/analysis/reenqueue",
|
|
json!({ "only_unanalyzed": true }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["enqueued"], 1, "only the un-analyzed page is enqueued");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn force_analyze_page_enqueues_with_force_flag(pool: PgPool) {
|
|
let h = common::harness_with_analysis(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
let ids = seed_pages(&pool, 1).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
&format!("/api/v1/admin/pages/{}/analyze", ids[0]),
|
|
json!({}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let payload: serde_json::Value = sqlx::query_scalar(
|
|
"SELECT payload FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(payload["force"].as_bool(), Some(true));
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn force_analyze_upgrades_pending_non_force_to_force(pool: PgPool) {
|
|
// The cover-up case the partial unique index used to silently
|
|
// create: when a `force=false` job is already pending and admin
|
|
// clicks force, the INSERT used to dedup-skip silently. The worker
|
|
// then picked up the non-force row, hit skip-if-done on a `done`
|
|
// page, and admin saw "queued" with no re-analysis. 0.87.11
|
|
// upgrades the pending row in-place instead.
|
|
let h = common::harness_with_analysis(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
let ids = seed_pages(&pool, 1).await;
|
|
let page_id = ids[0];
|
|
|
|
// 1) Pre-existing non-force pending row (simulates the crawler's
|
|
// post-upload enqueue path).
|
|
mangalord::repo::page_analysis::enqueue_for_page(&pool, page_id, false)
|
|
.await
|
|
.unwrap();
|
|
let payload_before: serde_json::Value = sqlx::query_scalar(
|
|
"SELECT payload FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(payload_before["force"].as_bool(), Some(false));
|
|
|
|
// 2) Admin force-click.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
&format!("/api/v1/admin/pages/{page_id}/analyze"),
|
|
json!({}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// 3) STILL one row (no dedup duplicate), but its `force` flag
|
|
// flipped to true — the admin intent landed where the worker
|
|
// can see it.
|
|
let rows: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rows, 1, "upgrade in place; no duplicate row");
|
|
|
|
let payload_after: serde_json::Value = sqlx::query_scalar(
|
|
"SELECT payload FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(payload_after["force"].as_bool(), Some(true));
|
|
|
|
// 4) Audit row carries the outcome so a moderator can tell
|
|
// upgrade-paths from fresh-inserts.
|
|
let audit_payload: serde_json::Value = sqlx::query_scalar(
|
|
"SELECT payload FROM admin_audit WHERE action = 'analysis_force_page'",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
audit_payload["outcome"].as_str(),
|
|
Some("upgraded_pending_to_force")
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn force_analyze_unknown_page_is_404(pool: PgPool) {
|
|
let h = common::harness_with_analysis(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
&format!("/api/v1/admin/pages/{}/analyze", uuid::Uuid::new_v4()),
|
|
json!({}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ---- coverage / inspection -------------------------------------------------
|
|
|
|
async fn get_json(h: &common::Harness, cookie: &str, uri: &str) -> serde_json::Value {
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(uri, cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK, "GET {uri} failed");
|
|
common::body_json(resp).await
|
|
}
|
|
|
|
/// Seed a manga (1 chapter, 2 pages), analyze the first, return ids.
|
|
async fn seed_partial(pool: &PgPool) -> (uuid::Uuid, uuid::Uuid, uuid::Uuid, uuid::Uuid) {
|
|
use mangalord::domain::page_analysis::{OcrResult, SafetyFlag, VisionAnalysis};
|
|
let (manga_id, chapter_id, ids) = seed_manga_chapter_pages(pool, 2).await;
|
|
repo::page_analysis::persist_analysis(
|
|
pool,
|
|
ids[0],
|
|
&VisionAnalysis {
|
|
ocr_results: vec![OcrResult {
|
|
text: "Hello".into(),
|
|
kind: "speech".into(),
|
|
y: None,
|
|
}],
|
|
tagging_results: vec!["action".into(), "city".into()],
|
|
scene_description: "A rainy street.".into(),
|
|
safety_flag: SafetyFlag {
|
|
is_nsfw: true,
|
|
content_type: vec!["gore".into()],
|
|
},
|
|
},
|
|
"test-model",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
(manga_id, chapter_id, ids[0], ids[1])
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn coverage_mangas_reports_analyzed_over_total(pool: PgPool) {
|
|
// Coverage works with the worker disabled — use the plain harness.
|
|
let h = common::harness(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
let (manga_id, _ch, _p1, _p2) = seed_partial(&pool).await;
|
|
|
|
let body = get_json(&h, &cookie, "/api/v1/admin/analysis/mangas?search=M").await;
|
|
let item = &body["items"][0];
|
|
assert_eq!(item["manga_id"].as_str().unwrap(), manga_id.to_string());
|
|
assert_eq!(item["total_pages"], 2);
|
|
assert_eq!(item["analyzed_pages"], 1);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn coverage_chapters_lists_per_chapter_counts(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
let (manga_id, chapter_id, _p1, _p2) = seed_partial(&pool).await;
|
|
|
|
let body = get_json(
|
|
&h,
|
|
&cookie,
|
|
&format!("/api/v1/admin/analysis/mangas/{manga_id}/chapters"),
|
|
)
|
|
.await;
|
|
let item = &body["items"][0];
|
|
assert_eq!(item["chapter_id"].as_str().unwrap(), chapter_id.to_string());
|
|
assert_eq!(item["total_pages"], 2);
|
|
assert_eq!(item["analyzed_pages"], 1);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn chapter_pages_reports_per_page_status(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
let (_m, chapter_id, p1, p2) = seed_partial(&pool).await;
|
|
|
|
let body = get_json(
|
|
&h,
|
|
&cookie,
|
|
&format!("/api/v1/admin/analysis/chapters/{chapter_id}/pages"),
|
|
)
|
|
.await;
|
|
let items = body["items"].as_array().unwrap();
|
|
assert_eq!(items.len(), 2);
|
|
let status_for = |id: uuid::Uuid| {
|
|
items
|
|
.iter()
|
|
.find(|i| i["page_id"].as_str().unwrap() == id.to_string())
|
|
.unwrap()["status"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string()
|
|
};
|
|
assert_eq!(status_for(p1), "done");
|
|
assert_eq!(status_for(p2), "none");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn page_detail_returns_full_result(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
let (_m, _ch, p1, p2) = seed_partial(&pool).await;
|
|
|
|
let d = get_json(&h, &cookie, &format!("/api/v1/admin/analysis/pages/{p1}")).await;
|
|
assert_eq!(d["status"], "done");
|
|
assert_eq!(d["is_nsfw"], true);
|
|
assert_eq!(d["scene_description"], "A rainy street.");
|
|
assert_eq!(d["model"], "test-model");
|
|
assert_eq!(d["ocr"][0]["kind"], "speech");
|
|
assert_eq!(d["ocr"][0]["text"], "Hello");
|
|
let tags: Vec<&str> = d["tags"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()).collect();
|
|
assert!(tags.contains(&"action") && tags.contains(&"city"));
|
|
assert_eq!(d["content_warnings"][0], "gore");
|
|
|
|
// Unanalyzed page → status "none", empty result.
|
|
let d2 = get_json(&h, &cookie, &format!("/api/v1/admin/analysis/pages/{p2}")).await;
|
|
assert_eq!(d2["status"], "none");
|
|
assert_eq!(d2["ocr"].as_array().unwrap().len(), 0);
|
|
assert_eq!(d2["tags"].as_array().unwrap().len(), 0);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn page_detail_unknown_page_is_404(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(
|
|
&format!("/api/v1/admin/analysis/pages/{}", uuid::Uuid::new_v4()),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn status_stream_is_admin_gated_and_event_stream(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
|
|
// Non-admin → 403 (don't consume the streaming body).
|
|
let (_u, cookie) = common::register_user(&h.app).await;
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(
|
|
"/api/v1/admin/analysis/status/stream",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
|
|
// Admin → 200 with an SSE content type.
|
|
let admin_cookie = seed_admin(&pool, &h.app).await;
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(
|
|
"/api/v1/admin/analysis/status/stream",
|
|
&admin_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();
|
|
assert!(ct.starts_with("text/event-stream"), "got content-type {ct:?}");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn coverage_requires_admin(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let (_u, cookie) = common::register_user(&h.app).await;
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(
|
|
"/api/v1/admin/analysis/mangas",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Analysis history: terminal-outcome log, searchable + filterable.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Seed a manga → chapter → one page, then write a terminal `page_analysis`
|
|
/// row for that page. Returns the page id.
|
|
async fn seed_analyzed_page(
|
|
pool: &PgPool,
|
|
title: &str,
|
|
status: &str,
|
|
is_nsfw: bool,
|
|
error: Option<&str>,
|
|
) -> uuid::Uuid {
|
|
let manga_id: uuid::Uuid =
|
|
sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
|
|
.bind(title)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap();
|
|
let chapter_id: uuid::Uuid = sqlx::query_scalar(
|
|
"INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id",
|
|
)
|
|
.bind(manga_id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap();
|
|
let page_id: uuid::Uuid = sqlx::query_scalar(
|
|
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
|
|
VALUES ($1, 1, 'k', 'image/png') RETURNING id",
|
|
)
|
|
.bind(chapter_id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO page_analysis (page_id, status, is_nsfw, model, error, analyzed_at) \
|
|
VALUES ($1, $2, $3, 'vis', $4, now())",
|
|
)
|
|
.bind(page_id)
|
|
.bind(status)
|
|
.bind(is_nsfw)
|
|
.bind(error)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
page_id
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn analysis_history_lists_filters_and_paginates(pool: PgPool) {
|
|
seed_analyzed_page(&pool, "Alpha", "done", false, None).await;
|
|
seed_analyzed_page(&pool, "Beta", "failed", false, Some("boom")).await;
|
|
seed_analyzed_page(&pool, "Gamma", "done", true, None).await;
|
|
// A still-pending row must NOT appear in history (terminal outcomes only).
|
|
let pending_page = seed_analyzed_page(&pool, "Delta", "pending", false, None).await;
|
|
let _ = pending_page;
|
|
|
|
let h = common::harness(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
|
|
// Unfiltered: the three terminal rows, pending excluded.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(
|
|
"/api/v1/admin/analysis/history",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["page"]["total"], 3);
|
|
|
|
// Filter by status=failed.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(
|
|
"/api/v1/admin/analysis/history?status=failed",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["page"]["total"], 1);
|
|
assert_eq!(body["items"][0]["manga_title"], "Beta");
|
|
assert_eq!(body["items"][0]["error"], "boom");
|
|
|
|
// NSFW-only.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(
|
|
"/api/v1/admin/analysis/history?nsfw=true",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["page"]["total"], 1);
|
|
assert_eq!(body["items"][0]["manga_title"], "Gamma");
|
|
|
|
// Search by manga title.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(
|
|
"/api/v1/admin/analysis/history?search=Alpha",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["page"]["total"], 1);
|
|
assert_eq!(body["items"][0]["manga_title"], "Alpha");
|
|
assert_eq!(body["items"][0]["status"], "done");
|
|
|
|
// Pagination: limit clamps the slice; total reflects the full set.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(
|
|
"/api/v1/admin/analysis/history?limit=1",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["items"].as_array().unwrap().len(), 1);
|
|
assert_eq!(body["page"]["total"], 3);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn analysis_history_requires_admin(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let (_u, cookie) = common::register_user(&h.app).await;
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(
|
|
"/api/v1/admin/analysis/history",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Analysis metrics: durations + averages + by-model + success.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Seed a manga→chapter→page + a terminal page_analysis row carrying a
|
|
/// duration and model. Returns the page id.
|
|
async fn seed_timed_page(
|
|
pool: &PgPool,
|
|
title: &str,
|
|
status: &str,
|
|
model: &str,
|
|
duration_ms: i64,
|
|
) -> uuid::Uuid {
|
|
let manga_id: uuid::Uuid =
|
|
sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
|
|
.bind(title)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap();
|
|
let chapter_id: uuid::Uuid = sqlx::query_scalar(
|
|
"INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id",
|
|
)
|
|
.bind(manga_id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap();
|
|
let page_id: uuid::Uuid = sqlx::query_scalar(
|
|
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
|
|
VALUES ($1, 1, 'k', 'image/png') RETURNING id",
|
|
)
|
|
.bind(chapter_id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO page_analysis (page_id, status, model, duration_ms, analyzed_at) \
|
|
VALUES ($1, $2, $3, $4, now())",
|
|
)
|
|
.bind(page_id)
|
|
.bind(status)
|
|
.bind(model)
|
|
.bind(duration_ms)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
page_id
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn analysis_metrics_over_http(pool: PgPool) {
|
|
seed_timed_page(&pool, "Alpha", "done", "qwen", 2000).await;
|
|
seed_timed_page(&pool, "Beta", "done", "qwen", 4000).await;
|
|
seed_timed_page(&pool, "Gamma", "done", "llava", 3000).await;
|
|
seed_timed_page(&pool, "Delta", "failed", "qwen", 1000).await;
|
|
|
|
let h = common::harness(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(
|
|
"/api/v1/admin/analysis/metrics",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["n"], 4);
|
|
assert_eq!(body["ok"], 3);
|
|
assert_eq!(body["failed"], 1);
|
|
// Headline mean is done-only (2000 + 4000 + 3000)/3 — the failed row's
|
|
// 1000ms is excluded so it reconciles with the by-model breakdown.
|
|
assert_eq!(body["avg_ms"], 3000.0);
|
|
|
|
// by_model: qwen has the done rows 2000 + 4000 → avg 3000, n 2
|
|
// (the failed qwen row is excluded — done only).
|
|
let by_model = body["by_model"].as_array().unwrap();
|
|
let qwen = by_model.iter().find(|m| m["model"] == "qwen").unwrap();
|
|
assert_eq!(qwen["n"], 2);
|
|
assert_eq!(qwen["avg_ms"], 3000.0);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn analysis_history_carries_duration(pool: PgPool) {
|
|
seed_timed_page(&pool, "Alpha", "done", "qwen", 2500).await;
|
|
let h = common::harness(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie(
|
|
"/api/v1/admin/analysis/history",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["items"][0]["duration_ms"], 2500);
|
|
}
|