//! 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) { 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 { 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") ); } /// The running-state branch of `force=true` collision. A worker has /// already leased the row (state=running, payload.force=false in its /// in-memory `lease.payload`). Admin force-clicks → my code must NOT /// only flip the row's payload to force=true (the worker holds the /// stale value) but also release the in-flight lease so the worker /// drops the stale payload and a fresh lease re-leases with the /// updated value. /// /// Mutation-confirmed: removing the `jobs::release` loop in /// `enqueue_for_page` (leaving only the UPDATE) makes this test fail /// — the row's state stays `running` until the lease expires. #[sqlx::test(migrations = "./migrations")] async fn force_analyze_releases_running_lease_so_worker_re_picks_force(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 page_id = ids[0]; // 1) Pre-existing non-force pending row. mangalord::repo::page_analysis::enqueue_for_page(&pool, page_id, false) .await .unwrap(); // 2) Simulate a worker leasing the job — state moves to `running` // with leased_until in the future and attempts=1. The worker // process (in real life) destructures its in-memory `lease.payload` // here, capturing `force=false` as a Rust local. let leases = mangalord::crawler::jobs::lease( &pool, Some(mangalord::crawler::jobs::KIND_ANALYZE_PAGE), 1, std::time::Duration::from_secs(300), ) .await .unwrap(); assert_eq!(leases.len(), 1, "test setup: should have leased the seeded job"); let leased_id = leases[0].id; // Sanity: row is now running with force=false in payload. let (state, force): (String, bool) = sqlx::query_as( "SELECT state, (payload->>'force')::boolean \ FROM crawler_jobs WHERE id = $1", ) .bind(leased_id) .fetch_one(&pool) .await .unwrap(); assert_eq!(state, "running"); assert!(!force); // 3) Admin force-clicks. Without 0.87.20's release loop, the row's // payload flips to force=true but state stays `running`; the // worker (holding its stale `force=false` local) acks done and // the page is never re-analyzed. With the release loop, the row // goes back to `pending`. 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); // 4) Row is back to pending, with the payload's force flag flipped // to true. A subsequent worker lease would now see `force=true` // and re-analyze. attempts is decremented by `jobs::release` so // the page doesn't burn its retry budget on the admin click. let (state, force, attempts): (String, bool, i32) = sqlx::query_as( "SELECT state, (payload->>'force')::boolean, attempts \ FROM crawler_jobs WHERE id = $1", ) .bind(leased_id) .fetch_one(&pool) .await .unwrap(); assert_eq!( state, "pending", "running lease must be released so the worker drops its stale-payload local" ); assert!(force, "payload's force flag must be flipped to true"); assert_eq!( attempts, 0, "release refunds the attempt — the admin click doesn't burn the retry budget" ); } #[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); }