feat(analysis): admin coverage + per-page inspection API
Read-only admin endpoints (admin-gated, not analysis-enabled-gated) for the dashboard's coverage overview and page-detail view: - GET /v1/admin/analysis/mangas?search= — paginated per-manga coverage (analyzed/total pages; only mangas with pages). - GET /v1/admin/analysis/mangas/:id/chapters — per-chapter coverage. - GET /v1/admin/analysis/chapters/:id/pages — per-page status grid (done | failed | queued | none). - GET /v1/admin/analysis/pages/:id — full result (status, is_nsfw, scene, model, analyzed_at, error, OCR lines with kind, tags, content warnings); "none" for an existing-but-unanalyzed page, 404 for a missing page. repo::page_analysis gains manga_coverage / chapter_coverage / chapter_page_status / page_detail; domain adds the matching row types. Tests: coverage counts (manga + chapter), per-page status, full detail + unanalyzed "none" + 404, non-admin 403. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -409,3 +409,157 @@ async fn force_analyze_unknown_page_is_404(pool: PgPool) {
|
||||
.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(),
|
||||
}],
|
||||
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 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user