feat(analysis): scoped admin re-enqueue (all/manga/chapter) + force-on-include

Generalizes the admin re-enqueue beyond global backfill:

- repo::page_analysis::enqueue_pages(scope, only_unanalyzed) with
  ReenqueueScope::{All,Manga,Chapter}. Fixes include-analyzed semantics:
  only_unanalyzed=false now enqueues jobs with force=true so the worker
  actually re-analyzes already-done pages instead of skipping them.
- POST /v1/admin/analysis/reenqueue accepts optional manga_id / chapter_id
  (mutually exclusive, 404 on unknown target) alongside only_unanalyzed.

Tests: manga/chapter scope counts, include-analyzed sets force=true on the
done page, mutual-exclusivity 422, unknown-target 404. Existing global
backfill tests still green (13 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 19:49:54 +02:00
parent d36f24e9af
commit 9607488278
6 changed files with 255 additions and 28 deletions

View File

@@ -25,9 +25,12 @@ async fn seed_admin(pool: &PgPool, app: &Router) -> String {
cookie
}
/// Seed a manga → chapter with `n` pages directly in the DB, returning the
/// page ids. Used by the backfill tests (no upload round-trip needed).
async fn seed_pages(pool: &PgPool, n: i32) -> Vec<uuid::Uuid> {
/// 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)
@@ -48,13 +51,18 @@ async fn seed_pages(pool: &PgPool, n: i32) -> Vec<uuid::Uuid> {
)
.bind(chapter_id)
.bind(i)
.bind(format!("k/{i}.png"))
.bind(format!("k/{manga_id}/{i}.png"))
.fetch_one(pool)
.await
.unwrap();
ids.push(id);
}
ids
(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 {
@@ -186,6 +194,140 @@ async fn reenqueue_backfills_existing_pages(pool: PgPool) {
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};