feat(search): enable OCR text search on page-tag aggregation endpoints

The `?text=` param on `/v1/me/page-tags/chapters` and `/mangas` was
reserved and returned 501 `text_search_not_yet_supported`. Now that OCR
populates `search_doc`, flip it to real search.

- `aggregate_chapters_for_tag` / `aggregate_mangas_for_tag` take an optional
  `text`; when non-blank they JOIN `page_analysis` and filter on
  `search_doc @@ plainto_tsquery('simple', $n)`, consistent with
  `repo::page_analysis::page_search`. `match_count` and the sample
  thumbnails reflect the filtered set. `text` is always bound, never
  interpolated.
- Drop `ensure_text_unsupported` and the 501 guard. `AppError::NotImplemented`
  stays as a generic variant for future reservations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-26 07:12:08 +02:00
parent cb34eeb82e
commit 82264c74cd
4 changed files with 217 additions and 54 deletions

View File

@@ -680,24 +680,123 @@ async fn aggregate_rejects_invalid_order(pool: PgPool) {
}
#[sqlx::test(migrations = "./migrations")]
async fn aggregate_with_text_param_is_501_with_stable_code(pool: PgPool) {
// OCR text search isn't built yet; the param is accepted so adding
// OCR won't break the wire shape, but rejected with a distinct
// status + code. The code is the wire contract — clients pin on
// `text_search_not_yet_supported`, not the message.
let h = common::harness(pool);
async fn aggregate_with_text_param_filters_by_ocr(pool: PgPool) {
// OCR text search: a page tagged `funny` whose OCR contains "guts" is
// returned for `&text=guts` on both aggregation endpoints, and excluded
// for a query its OCR doesn't contain. The filter runs against the same
// precomputed `search_doc` the OCR worker writes via `persist_analysis`.
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;
assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);
// Persist OCR text exactly as the ocrs backend would (via the same mapper).
let page_uuid = Uuid::parse_str(&page_id).unwrap();
let analysis =
mangalord::analysis::ocr::lines_to_analysis(vec!["spilling the guts here".to_string()]);
mangalord::repo::page_analysis::persist_analysis(&pool, page_uuid, &analysis, "ocrs")
.await
.unwrap();
for endpoint in ["chapters", "mangas"] {
// Matching text → the tagged+OCR'd row is returned.
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie(
&format!("/api/v1/me/page-tags/{endpoint}?tag=funny&text=guts"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "{endpoint} matching");
let body = common::body_json(resp).await;
assert_eq!(body["items"].as_array().unwrap().len(), 1, "{endpoint} matching items");
assert_eq!(body["page"]["total"], 1, "{endpoint} matching total");
// Non-matching text → excluded.
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie(
&format!("/api/v1/me/page-tags/{endpoint}?tag=funny&text=zzzznomatch"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "{endpoint} non-matching");
let body = common::body_json(resp).await;
assert_eq!(body["items"].as_array().unwrap().len(), 0, "{endpoint} non-matching items");
assert_eq!(body["page"]["total"], 0, "{endpoint} non-matching total");
}
}
#[sqlx::test(migrations = "./migrations")]
async fn aggregate_text_param_counts_only_matching_pages(pool: PgPool) {
// A chapter with TWO tagged pages where only ONE page's OCR matches the
// text. `match_count` / `total` must reflect the filtered count (1), not
// the tag-only count (2), and the sample thumbnails must contain only the
// matching page. This is what a single-page test can't distinguish — it
// pins the JOIN/placeholder wiring and the sample-subquery filter.
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
let (_, page_ids) = seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 1, 2).await;
for pid in &page_ids {
assert_eq!(add_tag(&h.app, &cookie, pid, "funny").await, StatusCode::CREATED);
}
// Page 0 OCR contains "guts"; page 1 OCR contains only "filler".
let p0 = Uuid::parse_str(&page_ids[0]).unwrap();
let p1 = Uuid::parse_str(&page_ids[1]).unwrap();
let a0 = mangalord::analysis::ocr::lines_to_analysis(vec!["the guts spill out".to_string()]);
let a1 = mangalord::analysis::ocr::lines_to_analysis(vec!["just filler text".to_string()]);
mangalord::repo::page_analysis::persist_analysis(&pool, p0, &a0, "ocrs").await.unwrap();
mangalord::repo::page_analysis::persist_analysis(&pool, p1, &a1, "ocrs").await.unwrap();
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie(
"/api/v1/me/page-tags/chapters?tag=funny&text=guts",
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "text_search_not_yet_supported");
let items = body["items"].as_array().unwrap();
assert_eq!(items.len(), 1, "one chapter row");
assert_eq!(body["page"]["total"], 1);
// Filtered match_count is the matching-page count, not the tag count.
assert_eq!(items[0]["match_count"], 1, "only one page's OCR matches");
// Sample thumbnails reflect the filter: only the matching page's key.
let samples = items[0]["sample_storage_keys"].as_array().unwrap();
assert_eq!(samples.len(), 1, "thumbnails restricted to matching pages");
}
#[sqlx::test(migrations = "./migrations")]
async fn aggregate_blank_text_param_is_tag_only(pool: PgPool) {
// A blank `text=` must not filter — it falls back to tag-only aggregation
// (the page has a tag but no analysis row at all).
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;
assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);
let resp = h
.app
.oneshot(common::get_with_cookie(
"/api/v1/me/page-tags/chapters?tag=funny&text=",
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["items"].as_array().unwrap().len(), 1);
}
#[sqlx::test(migrations = "./migrations")]