feat(mangas): content-warning filter on search + deduped banner on detail
Surfaces the analysis worker's NSFW moderation at the manga level: - repo::manga FILTER_WHERE gains cw_include (AND, page-content-warning join to mangas) and cw_exclude (NONE) clauses; ListQuery + binds renumbered ($6/$7, LIMIT/OFFSET $8/$9). - repo::page_analysis::warnings_for_manga: deduped, alphabetical union across all the manga's pages. - domain::MangaDetail gains content_warnings; get_detail populates it. - api::mangas list accepts cw_include/cw_exclude (reusing the shared parse_warnings_csv validator). Tests: detail union (deduped/sorted) + empty case; list include/exclude filter; unknown-warning 422. Existing manga tests still green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
140
backend/tests/api_manga_warnings.rs
Normal file
140
backend/tests/api_manga_warnings.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
//! Integration tests for manga-level content warnings: the deduped union
|
||||
//! on the detail endpoint and the include/exclude filters on the list.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::Router;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use mangalord::domain::page_analysis::{SafetyFlag, VisionAnalysis};
|
||||
use mangalord::repo;
|
||||
|
||||
/// Seed a manga whose pages carry the given per-page warning sets, and
|
||||
/// return the manga id.
|
||||
async fn seed_manga(pool: &PgPool, title: &str, pages: &[&[&str]]) -> Uuid {
|
||||
let manga_id: Uuid =
|
||||
sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
|
||||
.bind(title)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let chapter_id: Uuid =
|
||||
sqlx::query_scalar("INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id")
|
||||
.bind(manga_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
for (i, warnings) in pages.iter().enumerate() {
|
||||
let page_id: 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 + 1) as i32)
|
||||
.bind(format!("k/{i}.png"))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let analysis = VisionAnalysis {
|
||||
ocr_results: vec![],
|
||||
tagging_results: vec![],
|
||||
scene_description: String::new(),
|
||||
safety_flag: SafetyFlag {
|
||||
is_nsfw: !warnings.is_empty(),
|
||||
content_type: warnings.iter().map(|w| w.to_string()).collect(),
|
||||
},
|
||||
};
|
||||
repo::page_analysis::persist_analysis(pool, page_id, &analysis, "m")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
manga_id
|
||||
}
|
||||
|
||||
async fn list_ids(app: &Router, query: &str) -> Vec<String> {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::get(&format!("/api/v1/mangas?{query}")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
body["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|m| m["id"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn detail_returns_deduped_warning_union(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
// Two pages: one sexual+gore, one gore — union is [gore, sexual].
|
||||
let manga_id = seed_manga(&pool, "M", &[&["sexual", "gore"], &["gore"]]).await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get(&format!("/api/v1/mangas/{manga_id}")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
let warnings: Vec<String> = body["content_warnings"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|w| w.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(warnings, vec!["gore", "sexual"], "deduped + alphabetical");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn detail_has_empty_warnings_when_unflagged(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let manga_id = seed_manga(&pool, "Clean", &[&[]]).await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get(&format!("/api/v1/mangas/{manga_id}")))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["content_warnings"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_filters_by_content_warning(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let gory = seed_manga(&pool, "Gory", &[&["gore"]]).await;
|
||||
let sexy = seed_manga(&pool, "Sexy", &[&["sexual"]]).await;
|
||||
let clean = seed_manga(&pool, "Clean", &[&[]]).await;
|
||||
|
||||
// include=gore → only the gory manga.
|
||||
let ids = list_ids(&h.app, "cw_include=gore").await;
|
||||
assert_eq!(ids, vec![gory.to_string()]);
|
||||
|
||||
// exclude=gore → the others (sexy + clean), not gory.
|
||||
let mut ids = list_ids(&h.app, "cw_exclude=gore").await;
|
||||
ids.sort();
|
||||
let mut want = vec![sexy.to_string(), clean.to_string()];
|
||||
want.sort();
|
||||
assert_eq!(ids, want);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_rejects_unknown_warning(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get("/api/v1/mangas?cw_include=spicy"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
Reference in New Issue
Block a user