//! Integration tests for the page content-search endpoint //! `GET /v1/me/page-search`: multi-tag AND across (user ∪ auto) tags, //! weighted OCR/scene text ranking, and content-warning include/exclude. mod common; use axum::http::StatusCode; use axum::Router; use serde_json::json; use sqlx::PgPool; use tower::ServiceExt; use uuid::Uuid; use mangalord::domain::page_analysis::{OcrResult, SafetyFlag, VisionAnalysis}; use mangalord::repo; /// Seed a manga + chapter and return the chapter id for adding pages. async fn seed_chapter(pool: &PgPool, title: &str) -> Uuid { let manga_id: Uuid = sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id") .bind(title) .fetch_one(pool) .await .unwrap(); sqlx::query_scalar("INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id") .bind(manga_id) .fetch_one(pool) .await .unwrap() } async fn add_page(pool: &PgPool, chapter_id: Uuid, n: i32) -> 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(n) .bind(format!("k/{n}.png")) .fetch_one(pool) .await .unwrap() } fn analysis( ocr: &[(&str, &str)], auto_tags: &[&str], nsfw: bool, warnings: &[&str], ) -> VisionAnalysis { VisionAnalysis { ocr_results: ocr .iter() .map(|(t, k)| OcrResult { text: (*t).into(), kind: (*k).into(), y: None, }) .collect(), tagging_results: auto_tags.iter().map(|t| t.to_string()).collect(), scene_description: String::new(), safety_flag: SafetyFlag { is_nsfw: nsfw, content_type: warnings.iter().map(|w| w.to_string()).collect(), }, } } async fn user_id_for(pool: &PgPool, username: &str) -> Uuid { repo::user::find_by_username(pool, username) .await .unwrap() .unwrap() .id } async fn search(app: &Router, cookie: &str, query: &str) -> serde_json::Value { let resp = app .clone() .oneshot(common::get_with_cookie( &format!("/api/v1/me/page-search?{query}"), cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK, "search {query} failed"); common::body_json(resp).await } fn page_ids(body: &serde_json::Value) -> Vec { body["items"] .as_array() .unwrap() .iter() .map(|i| i["page_id"].as_str().unwrap().to_string()) .collect() } #[sqlx::test(migrations = "./migrations")] async fn multi_tag_and_across_user_and_auto_tags(pool: PgPool) { let h = common::harness(pool.clone()); let (username, cookie) = common::register_user(&h.app).await; let uid = user_id_for(&pool, &username).await; let ch = seed_chapter(&pool, "M").await; let p1 = add_page(&pool, ch, 1).await; let p2 = add_page(&pool, ch, 2).await; // p1: auto-tag "b"; user adds tag "a" → has both a (user) and b (auto). repo::page_analysis::persist_analysis(&pool, p1, &analysis(&[], &["b"], false, &[]), "m") .await .unwrap(); repo::page_tag::upsert(&pool, uid, p1, "a").await.unwrap(); // p2: only auto-tag "a". repo::page_analysis::persist_analysis(&pool, p2, &analysis(&[], &["a"], false, &[]), "m") .await .unwrap(); // tags=a,b (AND) → only p1 satisfies both. let body = search(&h.app, &cookie, "tags=a,b").await; assert_eq!(page_ids(&body), vec![p1.to_string()]); // tags=a alone → both (p1 via user tag, p2 via auto tag). let body = search(&h.app, &cookie, "tags=a").await; let mut ids = page_ids(&body); ids.sort(); let mut want = vec![p1.to_string(), p2.to_string()]; want.sort(); assert_eq!(ids, want); } #[sqlx::test(migrations = "./migrations")] async fn text_search_ranks_speech_above_sfx(pool: PgPool) { let h = common::harness(pool.clone()); let (_u, cookie) = common::register_user(&h.app).await; let ch = seed_chapter(&pool, "M").await; let speech_page = add_page(&pool, ch, 1).await; let sfx_page = add_page(&pool, ch, 2).await; repo::page_analysis::persist_analysis( &pool, speech_page, &analysis(&[("alpha", "speech")], &[], false, &[]), "m", ) .await .unwrap(); repo::page_analysis::persist_analysis( &pool, sfx_page, &analysis(&[("alpha", "sfx")], &[], false, &[]), "m", ) .await .unwrap(); let body = search(&h.app, &cookie, "text=alpha").await; let ids = page_ids(&body); assert_eq!(ids.len(), 2, "both pages match the term"); assert_eq!( ids[0], speech_page.to_string(), "speech (weight A) must rank above sfx (weight D)" ); } #[sqlx::test(migrations = "./migrations")] async fn content_warning_include_and_exclude(pool: PgPool) { let h = common::harness(pool.clone()); let (_u, cookie) = common::register_user(&h.app).await; let ch = seed_chapter(&pool, "M").await; let sexual = add_page(&pool, ch, 1).await; let gore = add_page(&pool, ch, 2).await; repo::page_analysis::persist_analysis( &pool, sexual, &analysis(&[("x", "speech")], &["nsfw-tag"], true, &["sexual"]), "m", ) .await .unwrap(); repo::page_analysis::persist_analysis( &pool, gore, &analysis(&[("x", "speech")], &["nsfw-tag"], true, &["gore"]), "m", ) .await .unwrap(); // include sexual → only the sexual page. let body = search(&h.app, &cookie, "tags=nsfw-tag&cw_include=sexual").await; assert_eq!(page_ids(&body), vec![sexual.to_string()]); // and it carries the warning + nsfw flag in the row. assert_eq!(body["items"][0]["is_nsfw"], true); assert_eq!(body["items"][0]["content_warnings"][0], "sexual"); // exclude sexual → only the gore page. let body = search(&h.app, &cookie, "tags=nsfw-tag&cw_exclude=sexual").await; assert_eq!(page_ids(&body), vec![gore.to_string()]); } #[sqlx::test(migrations = "./migrations")] async fn text_only_search_needs_no_tags(pool: PgPool) { let h = common::harness(pool.clone()); let (_u, cookie) = common::register_user(&h.app).await; let ch = seed_chapter(&pool, "M").await; let p = add_page(&pool, ch, 1).await; repo::page_analysis::persist_analysis( &pool, p, &analysis(&[("dragon", "speech")], &[], false, &[]), "m", ) .await .unwrap(); // The previously-501 text param now returns results (no tags needed). let body = search(&h.app, &cookie, "text=dragon").await; assert_eq!(page_ids(&body), vec![p.to_string()]); } #[sqlx::test(migrations = "./migrations")] async fn search_without_any_filter_is_rejected(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/me/page-search", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); } #[sqlx::test(migrations = "./migrations")] async fn unknown_content_warning_is_rejected(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/me/page-search?cw_include=spicy", &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); } #[sqlx::test(migrations = "./migrations")] async fn requires_authentication(pool: PgPool) { let h = common::harness(pool.clone()); let resp = h .app .clone() .oneshot(common::get("/api/v1/me/page-search?text=x")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); let _ = json!({}); // silence unused import in some configs }