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>
This commit is contained in:
MechaCat02
2026-06-13 18:58:07 +02:00
parent 7d80a437bf
commit af870bd157
8 changed files with 516 additions and 6 deletions

View File

@@ -0,0 +1,265 @@
//! 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
}