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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.74.1"
|
version = "0.75.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.74.1"
|
version = "0.75.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,19 @@
|
|||||||
//! * `POST /admin/pages/:id/analyze` — force re-analysis of a single page
|
//! * `POST /admin/pages/:id/analyze` — force re-analysis of a single page
|
||||||
//! (re-runs even if already `done`).
|
//! (re-runs even if already `done`).
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::routing::post;
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::api::pagination::PagedResponse;
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::auth::extractor::RequireAdmin;
|
use crate::auth::extractor::RequireAdmin;
|
||||||
|
use crate::domain::page_analysis::{
|
||||||
|
ChapterCoverage, MangaCoverage, PageAnalysisDetail, PageStatusItem,
|
||||||
|
};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::repo;
|
use crate::repo;
|
||||||
|
|
||||||
@@ -25,6 +29,88 @@ pub fn routes() -> Router<AppState> {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/admin/analysis/reenqueue", post(reenqueue))
|
.route("/admin/analysis/reenqueue", post(reenqueue))
|
||||||
.route("/admin/pages/:id/analyze", post(analyze_page))
|
.route("/admin/pages/:id/analyze", post(analyze_page))
|
||||||
|
// Coverage / inspection (admin-gated, but NOT analysis-enabled-gated
|
||||||
|
// — an operator can browse coverage with the worker off).
|
||||||
|
.route("/admin/analysis/mangas", get(coverage_mangas))
|
||||||
|
.route("/admin/analysis/mangas/:id/chapters", get(coverage_chapters))
|
||||||
|
.route("/admin/analysis/chapters/:id/pages", get(chapter_pages))
|
||||||
|
.route("/admin/analysis/pages/:id", get(page_detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CoverageParams {
|
||||||
|
#[serde(default)]
|
||||||
|
pub search: Option<String>,
|
||||||
|
#[serde(default = "default_coverage_limit")]
|
||||||
|
pub limit: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub offset: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_coverage_limit() -> i64 {
|
||||||
|
25
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn coverage_mangas(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_admin: RequireAdmin,
|
||||||
|
Query(params): Query<CoverageParams>,
|
||||||
|
) -> AppResult<Json<PagedResponse<MangaCoverage>>> {
|
||||||
|
let limit = params.limit.clamp(1, 100);
|
||||||
|
let offset = params.offset.max(0);
|
||||||
|
let search = params
|
||||||
|
.search
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let (items, total) =
|
||||||
|
repo::page_analysis::manga_coverage(&state.db, search, limit, offset).await?;
|
||||||
|
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn coverage_chapters(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_admin: RequireAdmin,
|
||||||
|
Path(manga_id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<ItemsResponse<ChapterCoverage>>> {
|
||||||
|
if !repo::manga::exists(&state.db, manga_id).await? {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
let items = repo::page_analysis::chapter_coverage(&state.db, manga_id).await?;
|
||||||
|
Ok(Json(ItemsResponse { items }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chapter_pages(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_admin: RequireAdmin,
|
||||||
|
Path(chapter_id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<ItemsResponse<PageStatusItem>>> {
|
||||||
|
let exists: bool =
|
||||||
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM chapters WHERE id = $1)")
|
||||||
|
.bind(chapter_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
if !exists {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
let items = repo::page_analysis::chapter_page_status(&state.db, chapter_id).await?;
|
||||||
|
Ok(Json(ItemsResponse { items }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn page_detail(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_admin: RequireAdmin,
|
||||||
|
Path(page_id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<PageAnalysisDetail>> {
|
||||||
|
let detail = repo::page_analysis::page_detail(&state.db, page_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
Ok(Json(detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ItemsResponse<T> {
|
||||||
|
items: Vec<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -128,6 +128,61 @@ pub struct PageSearchItem {
|
|||||||
pub rank: f32,
|
pub rank: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Analysis coverage for one manga (admin overview): how many of its pages
|
||||||
|
/// have a completed analysis vs. how many exist.
|
||||||
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
|
pub struct MangaCoverage {
|
||||||
|
pub manga_id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub total_pages: i64,
|
||||||
|
pub analyzed_pages: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Analysis coverage for one chapter.
|
||||||
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
|
pub struct ChapterCoverage {
|
||||||
|
pub chapter_id: Uuid,
|
||||||
|
pub number: i32,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub total_pages: i64,
|
||||||
|
pub analyzed_pages: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-page analysis status for a chapter's page grid. `status` is one of
|
||||||
|
/// `done` | `failed` | `queued` (a pending/running job) | `none`.
|
||||||
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
|
pub struct PageStatusItem {
|
||||||
|
pub page_id: Uuid,
|
||||||
|
pub page_number: i32,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One OCR line in the page-detail view.
|
||||||
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
|
pub struct OcrLine {
|
||||||
|
pub kind: OcrKind,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full analysis result for one page, for the admin detail modal. `status`
|
||||||
|
/// is `done` | `failed` | `none` (no analysis row yet).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PageAnalysisDetail {
|
||||||
|
pub page_id: Uuid,
|
||||||
|
pub page_number: i32,
|
||||||
|
pub chapter_id: Uuid,
|
||||||
|
pub manga_id: Uuid,
|
||||||
|
pub status: String,
|
||||||
|
pub is_nsfw: bool,
|
||||||
|
pub scene_description: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub analyzed_at: Option<DateTime<Utc>>,
|
||||||
|
pub ocr: Vec<OcrLine>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub content_warnings: Vec<ContentWarning>,
|
||||||
|
}
|
||||||
|
|
||||||
/// One persisted `page_analysis` row.
|
/// One persisted `page_analysis` row.
|
||||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
pub struct PageAnalysis {
|
pub struct PageAnalysis {
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::crawler::jobs::{self, JobPayload};
|
use crate::crawler::jobs::{self, JobPayload};
|
||||||
use crate::domain::page_analysis::{
|
use crate::domain::page_analysis::{
|
||||||
ContentWarning, OcrKind, PageAnalysis, PageSearchItem, VisionAnalysis,
|
ChapterCoverage, ContentWarning, MangaCoverage, OcrKind, OcrLine, PageAnalysis,
|
||||||
|
PageAnalysisDetail, PageSearchItem, PageStatusItem, VisionAnalysis,
|
||||||
};
|
};
|
||||||
use crate::error::AppResult;
|
use crate::error::AppResult;
|
||||||
|
|
||||||
@@ -107,6 +108,173 @@ pub async fn enqueue_pages(
|
|||||||
Ok(query.execute(pool).await?.rows_affected())
|
Ok(query.execute(pool).await?.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-manga analysis coverage for the admin overview. Only mangas that
|
||||||
|
/// have at least one page appear (nothing to analyze otherwise). `search`
|
||||||
|
/// filters by title (case-insensitive substring). Ordered by title.
|
||||||
|
/// Returns the page plus the total matching-manga count for pagination.
|
||||||
|
pub async fn manga_coverage(
|
||||||
|
pool: &PgPool,
|
||||||
|
search: Option<&str>,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> AppResult<(Vec<MangaCoverage>, i64)> {
|
||||||
|
let rows = sqlx::query_as::<_, MangaCoverage>(
|
||||||
|
r#"
|
||||||
|
SELECT m.id AS manga_id, m.title,
|
||||||
|
count(p.id) AS total_pages,
|
||||||
|
count(*) FILTER (WHERE pa.status = 'done') AS analyzed_pages
|
||||||
|
FROM mangas m
|
||||||
|
JOIN chapters c ON c.manga_id = m.id
|
||||||
|
JOIN pages p ON p.chapter_id = c.id
|
||||||
|
LEFT JOIN page_analysis pa ON pa.page_id = p.id
|
||||||
|
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%')
|
||||||
|
GROUP BY m.id, m.title
|
||||||
|
ORDER BY lower(m.title), m.id
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(search)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (total,): (i64,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT count(*) FROM (
|
||||||
|
SELECT m.id
|
||||||
|
FROM mangas m
|
||||||
|
JOIN chapters c ON c.manga_id = m.id
|
||||||
|
JOIN pages p ON p.chapter_id = c.id
|
||||||
|
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%')
|
||||||
|
GROUP BY m.id
|
||||||
|
) x
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(search)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok((rows, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-chapter analysis coverage for one manga, ordered by chapter number.
|
||||||
|
pub async fn chapter_coverage(
|
||||||
|
pool: &PgPool,
|
||||||
|
manga_id: uuid::Uuid,
|
||||||
|
) -> AppResult<Vec<ChapterCoverage>> {
|
||||||
|
let rows = sqlx::query_as::<_, ChapterCoverage>(
|
||||||
|
r#"
|
||||||
|
SELECT c.id AS chapter_id, c.number, c.title,
|
||||||
|
count(p.id) AS total_pages,
|
||||||
|
count(*) FILTER (WHERE pa.status = 'done') AS analyzed_pages
|
||||||
|
FROM chapters c
|
||||||
|
JOIN pages p ON p.chapter_id = c.id
|
||||||
|
LEFT JOIN page_analysis pa ON pa.page_id = p.id
|
||||||
|
WHERE c.manga_id = $1
|
||||||
|
GROUP BY c.id, c.number, c.title
|
||||||
|
ORDER BY c.number, c.id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(manga_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-page status grid for a chapter. `status` resolves to the analysis
|
||||||
|
/// row's state (`done`/`failed`), else `queued` when a pending/running
|
||||||
|
/// analyze_page job exists, else `none`.
|
||||||
|
pub async fn chapter_page_status(
|
||||||
|
pool: &PgPool,
|
||||||
|
chapter_id: uuid::Uuid,
|
||||||
|
) -> AppResult<Vec<PageStatusItem>> {
|
||||||
|
let rows = sqlx::query_as::<_, PageStatusItem>(
|
||||||
|
r#"
|
||||||
|
SELECT p.id AS page_id, p.page_number,
|
||||||
|
COALESCE(
|
||||||
|
pa.status,
|
||||||
|
CASE WHEN EXISTS (
|
||||||
|
SELECT 1 FROM crawler_jobs j
|
||||||
|
WHERE j.payload->>'kind' = 'analyze_page'
|
||||||
|
AND j.payload->>'page_id' = p.id::text
|
||||||
|
AND j.state IN ('pending', 'running')
|
||||||
|
) THEN 'queued' ELSE 'none' END
|
||||||
|
) AS status
|
||||||
|
FROM pages p
|
||||||
|
LEFT JOIN page_analysis pa ON pa.page_id = p.id
|
||||||
|
WHERE p.chapter_id = $1
|
||||||
|
ORDER BY p.page_number
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(chapter_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full analysis detail for one page. `None` when the page itself doesn't
|
||||||
|
/// exist; an existing-but-unanalyzed page returns `status = "none"` with
|
||||||
|
/// empty OCR/tags/warnings so the UI can show "not analyzed yet".
|
||||||
|
pub async fn page_detail(
|
||||||
|
pool: &PgPool,
|
||||||
|
page_id: uuid::Uuid,
|
||||||
|
) -> AppResult<Option<PageAnalysisDetail>> {
|
||||||
|
let Some((page_number, chapter_id, manga_id)): Option<(i32, uuid::Uuid, uuid::Uuid)> =
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT p.page_number, p.chapter_id, c.manga_id \
|
||||||
|
FROM pages p JOIN chapters c ON c.id = p.chapter_id WHERE p.id = $1",
|
||||||
|
)
|
||||||
|
.bind(page_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let analysis = load(pool, page_id).await?;
|
||||||
|
|
||||||
|
let ocr = sqlx::query_as::<_, OcrLine>(
|
||||||
|
"SELECT kind, text FROM page_ocr_text WHERE page_id = $1 ORDER BY ord, id",
|
||||||
|
)
|
||||||
|
.bind(page_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let tags: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT t.name FROM page_auto_tags pat JOIN tags t ON t.id = pat.tag_id \
|
||||||
|
WHERE pat.page_id = $1 ORDER BY t.name",
|
||||||
|
)
|
||||||
|
.bind(page_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let content_warnings = sqlx::query_scalar::<_, ContentWarning>(
|
||||||
|
"SELECT warning FROM page_content_warnings WHERE page_id = $1 ORDER BY warning",
|
||||||
|
)
|
||||||
|
.bind(page_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Some(PageAnalysisDetail {
|
||||||
|
page_id,
|
||||||
|
page_number,
|
||||||
|
chapter_id,
|
||||||
|
manga_id,
|
||||||
|
status: analysis
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| format!("{:?}", a.status).to_lowercase())
|
||||||
|
.unwrap_or_else(|| "none".to_string()),
|
||||||
|
is_nsfw: analysis.as_ref().map(|a| a.is_nsfw).unwrap_or(false),
|
||||||
|
scene_description: analysis.as_ref().and_then(|a| a.scene_description.clone()),
|
||||||
|
model: analysis.as_ref().and_then(|a| a.model.clone()),
|
||||||
|
error: analysis.as_ref().and_then(|a| a.error.clone()),
|
||||||
|
analyzed_at: analysis.as_ref().and_then(|a| a.analyzed_at),
|
||||||
|
ocr,
|
||||||
|
tags,
|
||||||
|
content_warnings,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Deduplicated union of content warnings across all of a manga's pages,
|
/// Deduplicated union of content warnings across all of a manga's pages,
|
||||||
/// ordered alphabetically. Powers the manga-detail content-warning banner.
|
/// ordered alphabetically. Powers the manga-detail content-warning banner.
|
||||||
pub async fn warnings_for_manga(
|
pub async fn warnings_for_manga(
|
||||||
|
|||||||
@@ -409,3 +409,157 @@ async fn force_analyze_unknown_page_is_404(pool: PgPool) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.74.1",
|
"version": "0.75.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user