Files
Mangalord/backend/tests/api_page_search.rs
MechaCat02 af870bd157 feat(search): page content-search endpoint (multi-tag AND + text + warnings)
New GET /v1/me/page-search surfacing the analysis worker's output:

- repo::page_analysis::page_search + PageSearchQuery: multi-tag AND across
  (user page_tags ∪ global page_auto_tags) via the unnest double-negative
  idiom, weighted OCR/scene text ranking (ts_rank over search_doc), and
  content-warning include/exclude. One row per page with is_nsfw +
  deduped content_warnings + rank.
- domain::PageSearchItem.
- api::page_tags: /me/page-search handler with CSV tag/warning parsing
  (parse_tags_csv reuses normalize_tag; parse_warnings_csv validates the
  closed vocabulary), requiring at least one positive filter (422 else).
  This is where the reserved OCR text search lands for pages.

Tests: multi-tag AND user∪auto, speech>sfx ranking, cw include/exclude
(+ row flags), text-only (no tags), missing-filter 422, unknown-warning
422, auth required.

Note: the /me/page-tags/chapters|mangas aggregations keep single-tag
behavior + the reserved text=501 for now; page-level search is the
primary text surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:58:07 +02:00

266 lines
8.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(),
})
.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<String> {
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
}