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]]
|
||||
name = "mangalord"
|
||||
version = "0.74.1"
|
||||
version = "0.75.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.74.1"
|
||||
version = "0.75.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -9,15 +9,19 @@
|
||||
//! * `POST /admin/pages/:id/analyze` — force re-analysis of a single page
|
||||
//! (re-runs even if already `done`).
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::post;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::RequireAdmin;
|
||||
use crate::domain::page_analysis::{
|
||||
ChapterCoverage, MangaCoverage, PageAnalysisDetail, PageStatusItem,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
|
||||
@@ -25,6 +29,88 @@ pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/analysis/reenqueue", post(reenqueue))
|
||||
.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)]
|
||||
|
||||
@@ -128,6 +128,61 @@ pub struct PageSearchItem {
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct PageAnalysis {
|
||||
|
||||
@@ -16,7 +16,8 @@ use uuid::Uuid;
|
||||
|
||||
use crate::crawler::jobs::{self, JobPayload};
|
||||
use crate::domain::page_analysis::{
|
||||
ContentWarning, OcrKind, PageAnalysis, PageSearchItem, VisionAnalysis,
|
||||
ChapterCoverage, ContentWarning, MangaCoverage, OcrKind, OcrLine, PageAnalysis,
|
||||
PageAnalysisDetail, PageSearchItem, PageStatusItem, VisionAnalysis,
|
||||
};
|
||||
use crate::error::AppResult;
|
||||
|
||||
@@ -107,6 +108,173 @@ pub async fn enqueue_pages(
|
||||
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,
|
||||
/// ordered alphabetically. Powers the manga-detail content-warning banner.
|
||||
pub async fn warnings_for_manga(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.74.1",
|
||||
"version": "0.75.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user