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>
266 lines
8.0 KiB
Rust
266 lines
8.0 KiB
Rust
//! 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
|
||
}
|