//! 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 { 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 = 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); }