feat(search): tag-based page search surface + per-page tags & collections
Add the /search surface (Pages / Chapters / Mangas tabs) backed by per-user page tags and per-page collections: schema (migration 0023), backend endpoints for page tags/collections and tagged-page aggregations (with the OCR text-search param reserved at 501), plus the frontend API clients, library Page-tags tab, collection page sections, page context menu / AddTagsSheet, and reader long-press wiring. Includes the continuous-reader navigation fixes (?page=N handling, chapter-reset timing, back-button pops history) and tag-normalization hardening accumulated on the branch. Bump version 0.60.2 -> 0.62.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
331
backend/tests/api_collection_pages.rs
Normal file
331
backend/tests/api_collection_pages.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
//! Integration tests for the page-level collection endpoints:
|
||||
//!
|
||||
//! - `POST /v1/collections/:id/pages`
|
||||
//! - `DELETE /v1/collections/:id/pages/:page_id`
|
||||
//! - `GET /v1/collections/:id/pages`
|
||||
//! - `GET /v1/pages/:id/my-collections`
|
||||
//!
|
||||
//! These exercise migration 0023's `collection_pages` table end-to-end,
|
||||
//! plus the owner-only / 404-non-existence-leak / idempotency / cascade
|
||||
//! invariants that mirror `api_collections.rs`.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use common::MultipartBuilder;
|
||||
|
||||
async fn create_collection(app: &axum::Router, cookie: &str, name: &str) -> Value {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/collections",
|
||||
json!({ "name": name }),
|
||||
cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
common::body_json(resp).await
|
||||
}
|
||||
|
||||
/// Create a chapter with a single page so we have a real `pages.id` to
|
||||
/// attach to. Returns `(chapter_id, page_id)`.
|
||||
async fn seed_chapter_with_page(
|
||||
app: &axum::Router,
|
||||
cookie: &str,
|
||||
manga_id: Uuid,
|
||||
number: i32,
|
||||
) -> (String, String) {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::post_multipart_with_cookie(
|
||||
&format!("/api/v1/mangas/{manga_id}/chapters"),
|
||||
MultipartBuilder::new()
|
||||
.add_json("metadata", json!({ "number": number }))
|
||||
.add_file("page", "1.png", "image/png", &common::fake_png_bytes()),
|
||||
cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let chapter = common::body_json(resp).await;
|
||||
let chapter_id = chapter["id"].as_str().unwrap().to_string();
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/mangas/{manga_id}/chapters/{chapter_id}/pages"),
|
||||
cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
let page_id = body["pages"][0]["id"].as_str().unwrap().to_string();
|
||||
(chapter_id, page_id)
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> String {
|
||||
v["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn add_page_then_list_returns_breadcrumb(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
||||
let (chapter_id, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;
|
||||
let coll = create_collection(&h.app, &cookie, "Favorite panels").await;
|
||||
let coll_id = id_of(&coll);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/collections/{coll_id}/pages"),
|
||||
json!({ "page_id": page_id }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/collections/{coll_id}/pages"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
let items = body["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
let item = &items[0];
|
||||
assert_eq!(item["page_id"], page_id);
|
||||
assert_eq!(item["chapter_id"], chapter_id);
|
||||
assert_eq!(item["manga_id"], manga_id.to_string());
|
||||
assert_eq!(item["manga_title"], "Berserk");
|
||||
assert_eq!(item["chapter_number"], 1);
|
||||
assert_eq!(item["page_number"], 1);
|
||||
assert!(
|
||||
item["storage_key"].as_str().unwrap().starts_with(&format!(
|
||||
"mangas/{manga_id}/chapters/{chapter_id}/pages/"
|
||||
)),
|
||||
"unexpected storage_key: {}",
|
||||
item["storage_key"]
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn add_page_is_idempotent_and_picks_201_then_200(pool: PgPool) {
|
||||
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;
|
||||
let coll = create_collection(&h.app, &cookie, "C").await;
|
||||
let coll_id = id_of(&coll);
|
||||
|
||||
let req = || {
|
||||
common::post_json_with_cookie(
|
||||
&format!("/api/v1/collections/{coll_id}/pages"),
|
||||
json!({ "page_id": page_id }),
|
||||
&cookie,
|
||||
)
|
||||
};
|
||||
let first = h.app.clone().oneshot(req()).await.unwrap();
|
||||
assert_eq!(first.status(), StatusCode::CREATED);
|
||||
let second = h.app.oneshot(req()).await.unwrap();
|
||||
assert_eq!(second.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn add_page_returns_404_when_page_missing(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let coll = create_collection(&h.app, &cookie, "C").await;
|
||||
let coll_id = id_of(&coll);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/collections/{coll_id}/pages"),
|
||||
json!({ "page_id": Uuid::new_v4().to_string() }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn add_page_to_other_users_collection_is_404(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, a) = common::register_user(&h.app).await;
|
||||
let (_, b) = common::register_user(&h.app).await;
|
||||
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &b, "M").await;
|
||||
let (_, page_id) = seed_chapter_with_page(&h.app, &b, manga_id, 1).await;
|
||||
let coll_a = create_collection(&h.app, &a, "A's").await;
|
||||
let coll_a_id = id_of(&coll_a);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/collections/{coll_a_id}/pages"),
|
||||
json!({ "page_id": page_id }),
|
||||
&b,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
// Same non-leak semantic as the manga-level handler.
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn remove_page_is_idempotent(pool: PgPool) {
|
||||
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;
|
||||
let coll = create_collection(&h.app, &cookie, "C").await;
|
||||
let coll_id = id_of(&coll);
|
||||
|
||||
let _ = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/collections/{coll_id}/pages"),
|
||||
json!({ "page_id": page_id }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let first = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::delete_with_cookie(
|
||||
&format!("/api/v1/collections/{coll_id}/pages/{page_id}"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), StatusCode::NO_CONTENT);
|
||||
let second = h
|
||||
.app
|
||||
.oneshot(common::delete_with_cookie(
|
||||
&format!("/api/v1/collections/{coll_id}/pages/{page_id}"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn my_collections_for_page_lists_only_owned(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, a) = common::register_user(&h.app).await;
|
||||
let (_, b) = common::register_user(&h.app).await;
|
||||
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &a, "M").await;
|
||||
let (_, page_id) = seed_chapter_with_page(&h.app, &a, manga_id, 1).await;
|
||||
|
||||
let a_coll = create_collection(&h.app, &a, "A").await;
|
||||
let b_coll = create_collection(&h.app, &b, "B").await;
|
||||
let a_coll_id = id_of(&a_coll);
|
||||
let b_coll_id = id_of(&b_coll);
|
||||
|
||||
for (coll, cookie) in [(&a_coll_id, &a), (&b_coll_id, &b)] {
|
||||
let _ = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/collections/{coll}/pages"),
|
||||
json!({ "page_id": page_id }),
|
||||
cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/pages/{page_id}/my-collections"),
|
||||
&a,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
let ids: Vec<&str> = body["collection_ids"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(ids, vec![a_coll_id.as_str()]);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn my_collections_for_unknown_page_returns_empty_list(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/pages/{}/my-collections", Uuid::new_v4()),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["collection_ids"], json!([]));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn add_page_requires_authentication(pool: PgPool) {
|
||||
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;
|
||||
let coll = create_collection(&h.app, &cookie, "C").await;
|
||||
let coll_id = id_of(&coll);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json(
|
||||
&format!("/api/v1/collections/{coll_id}/pages"),
|
||||
json!({ "page_id": page_id }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_pages_in_others_collection_is_404(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, a) = common::register_user(&h.app).await;
|
||||
let (_, b) = common::register_user(&h.app).await;
|
||||
let coll = create_collection(&h.app, &a, "Mine").await;
|
||||
let coll_id = id_of(&coll);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/collections/{coll_id}/pages"),
|
||||
&b,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
646
backend/tests/api_page_tags.rs
Normal file
646
backend/tests/api_page_tags.rs
Normal file
@@ -0,0 +1,646 @@
|
||||
//! Integration tests for the page-tag endpoints:
|
||||
//!
|
||||
//! - `POST /v1/pages/:id/tags`
|
||||
//! - `DELETE /v1/pages/:id/tags/:tag`
|
||||
//! - `GET /v1/pages/:id/my-tags`
|
||||
//! - `GET /v1/me/page-tags`
|
||||
//! - `GET /v1/me/page-tags/distinct`
|
||||
//! - `GET /v1/me/page-tags/chapters`
|
||||
//! - `GET /v1/me/page-tags/mangas`
|
||||
//!
|
||||
//! Together with `repo::page_tag` and migration 0023's `page_tags`
|
||||
//! table. Validation behaviour and the page-deletion cascade are
|
||||
//! pinned here.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use common::MultipartBuilder;
|
||||
|
||||
async fn seed_chapter_with_page(
|
||||
app: &axum::Router,
|
||||
cookie: &str,
|
||||
manga_id: Uuid,
|
||||
number: i32,
|
||||
) -> (String, String) {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::post_multipart_with_cookie(
|
||||
&format!("/api/v1/mangas/{manga_id}/chapters"),
|
||||
MultipartBuilder::new()
|
||||
.add_json("metadata", json!({ "number": number }))
|
||||
.add_file("page", "1.png", "image/png", &common::fake_png_bytes()),
|
||||
cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let chapter = common::body_json(resp).await;
|
||||
let chapter_id = chapter["id"].as_str().unwrap().to_string();
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/mangas/{manga_id}/chapters/{chapter_id}/pages"),
|
||||
cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
let page_id = body["pages"][0]["id"].as_str().unwrap().to_string();
|
||||
(chapter_id, page_id)
|
||||
}
|
||||
|
||||
/// Like `seed_chapter_with_page` but uploads `n` pages so the
|
||||
/// aggregation tests can tag a subset and verify match counts.
|
||||
/// Returns `(chapter_id, page_ids_in_order)`.
|
||||
async fn seed_chapter_with_n_pages(
|
||||
app: &axum::Router,
|
||||
cookie: &str,
|
||||
manga_id: Uuid,
|
||||
number: i32,
|
||||
n: usize,
|
||||
) -> (String, Vec<String>) {
|
||||
let mut builder = MultipartBuilder::new()
|
||||
.add_json("metadata", json!({ "number": number }));
|
||||
for i in 0..n {
|
||||
let fname = format!("{:04}.png", i + 1);
|
||||
builder = builder.add_file("page", &fname, "image/png", &common::fake_png_bytes());
|
||||
}
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::post_multipart_with_cookie(
|
||||
&format!("/api/v1/mangas/{manga_id}/chapters"),
|
||||
builder,
|
||||
cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let chapter = common::body_json(resp).await;
|
||||
let chapter_id = chapter["id"].as_str().unwrap().to_string();
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/mangas/{manga_id}/chapters/{chapter_id}/pages"),
|
||||
cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
let page_ids: Vec<String> = body["pages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
(chapter_id, page_ids)
|
||||
}
|
||||
|
||||
async fn add_tag(
|
||||
app: &axum::Router,
|
||||
cookie: &str,
|
||||
page_id: &str,
|
||||
tag: &str,
|
||||
) -> StatusCode {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/pages/{page_id}/tags"),
|
||||
json!({ "tag": tag }),
|
||||
cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
resp.status()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn add_then_list_returns_normalized_tag(pool: PgPool) {
|
||||
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(
|
||||
&format!("/api/v1/pages/{page_id}/my-tags"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
// Stored as normalized "funny".
|
||||
assert_eq!(body["tags"], json!(["funny"]));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn add_is_idempotent_picks_201_then_200(pool: PgPool) {
|
||||
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);
|
||||
// Even after re-casing, the normalized form collides → 200.
|
||||
assert_eq!(add_tag(&h.app, &cookie, &page_id, "FUNNY").await, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn add_unknown_page_is_404(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/pages/{}/tags", Uuid::new_v4()),
|
||||
json!({ "tag": "funny" }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn add_blank_tag_is_422(pool: PgPool) {
|
||||
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;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/pages/{page_id}/tags"),
|
||||
json!({ "tag": " " }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn add_tag_too_long_is_422(pool: PgPool) {
|
||||
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;
|
||||
|
||||
let long = "a".repeat(65);
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/pages/{page_id}/tags"),
|
||||
json!({ "tag": long }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn add_tag_with_invisible_format_char_is_422(pool: PgPool) {
|
||||
// Without the format-char guard, `"funny"` and `"funny\u{200d}"`
|
||||
// (zero-width joiner appended) would be stored as distinct rows,
|
||||
// visually identical to the user and indistinguishable in the
|
||||
// chip cloud. Same risk for bidi overrides and BOM.
|
||||
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;
|
||||
|
||||
for bad in [
|
||||
"funny\u{200d}", // ZWJ
|
||||
"a\u{202e}b", // RLO
|
||||
"a\u{feff}b", // ZWNBSP / BOM
|
||||
"a\u{200b}b", // ZWSP
|
||||
] {
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/pages/{page_id}/tags"),
|
||||
json!({ "tag": bad }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"expected 422 for tag containing invisible char (bytes: {:?})",
|
||||
bad.as_bytes()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn add_tag_with_path_breaking_char_is_422(pool: PgPool) {
|
||||
// Without this guard, `"a/b"` would be storable via POST (JSON
|
||||
// body) but unremovable via DELETE (axum decodes %2F back to `/`
|
||||
// and the `:tag` segment never matches). The 422 keeps every
|
||||
// stored tag round-trippable.
|
||||
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;
|
||||
|
||||
for bad in ["a/b", "a?b", "a#b", "a%b", "a_b", "a\\b"] {
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/pages/{page_id}/tags"),
|
||||
json!({ "tag": bad }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"expected 422 for tag {bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn add_tag_with_control_char_is_422(pool: PgPool) {
|
||||
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;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/pages/{page_id}/tags"),
|
||||
json!({ "tag": "bad\ntag" }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn remove_normalizes_url_tag(pool: PgPool) {
|
||||
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);
|
||||
|
||||
// DELETE arrives with the original case; the handler renormalizes
|
||||
// so it still matches "funny" in storage.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::delete_with_cookie(
|
||||
&format!("/api/v1/pages/{page_id}/tags/FUNNY"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/pages/{page_id}/my-tags"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body: Value = common::body_json(resp).await;
|
||||
assert_eq!(body["tags"], json!([]));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_mine_filters_by_tag(pool: PgPool) {
|
||||
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);
|
||||
assert_eq!(add_tag(&h.app, &cookie, &page_id, "fight").await, StatusCode::CREATED);
|
||||
assert_eq!(add_tag(&h.app, &cookie, &page_id, "panel-of-the-day").await, StatusCode::CREATED);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get_with_cookie("/api/v1/me/page-tags?tag=fight", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
let items = body["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["tag"], "fight");
|
||||
assert_eq!(items[0]["page_id"], page_id);
|
||||
assert_eq!(items[0]["manga_title"], "M");
|
||||
|
||||
// Prefix filter ?q=fu matches "funny".
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie("/api/v1/me/page-tags?q=fu", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
let tags: Vec<&str> = body["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v["tag"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(tags, vec!["funny"]);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn distinct_lists_counts_per_tag(pool: PgPool) {
|
||||
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 (_, p1) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;
|
||||
let (_, p2) = seed_chapter_with_page(&h.app, &cookie, manga_id, 2).await;
|
||||
|
||||
// "funny" appears twice; "fight" once. Distinct should reflect that.
|
||||
assert_eq!(add_tag(&h.app, &cookie, &p1, "funny").await, StatusCode::CREATED);
|
||||
assert_eq!(add_tag(&h.app, &cookie, &p2, "funny").await, StatusCode::CREATED);
|
||||
assert_eq!(add_tag(&h.app, &cookie, &p1, "fight").await, StatusCode::CREATED);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie("/api/v1/me/page-tags/distinct", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
let items = body["items"].as_array().unwrap();
|
||||
// Ordered by count DESC, tag ASC.
|
||||
assert_eq!(items[0]["tag"], "funny");
|
||||
assert_eq!(items[0]["count"], 2);
|
||||
assert_eq!(items[1]["tag"], "fight");
|
||||
assert_eq!(items[1]["count"], 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn tags_are_per_user_only(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, a) = common::register_user(&h.app).await;
|
||||
let (_, b) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &a, "M").await;
|
||||
let (_, page_id) = seed_chapter_with_page(&h.app, &a, manga_id, 1).await;
|
||||
|
||||
assert_eq!(add_tag(&h.app, &a, &page_id, "funny").await, StatusCode::CREATED);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/pages/{page_id}/my-tags"),
|
||||
&b,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["tags"], json!([]));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn tags_require_authentication(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json(
|
||||
&format!("/api/v1/pages/{}/tags", Uuid::new_v4()),
|
||||
json!({ "tag": "funny" }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Aggregation endpoints (`/me/page-tags/{chapters,mangas}`).
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn chapters_aggregate_groups_and_ranks_by_match_count(pool: PgPool) {
|
||||
// Chapter A in manga Berserk gets 3 pages tagged "funny";
|
||||
// chapter B (same manga) gets 1. Desc order → A first.
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
||||
|
||||
let (chapter_a, pages_a) =
|
||||
seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 1, 3).await;
|
||||
let (chapter_b, pages_b) =
|
||||
seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 2, 2).await;
|
||||
|
||||
for p in &pages_a {
|
||||
assert_eq!(add_tag(&h.app, &cookie, p, "funny").await, StatusCode::CREATED);
|
||||
}
|
||||
assert_eq!(add_tag(&h.app, &cookie, &pages_b[0], "funny").await, StatusCode::CREATED);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
"/api/v1/me/page-tags/chapters?tag=funny",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
let items = body["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0]["chapter_id"], chapter_a);
|
||||
assert_eq!(items[0]["match_count"], 3);
|
||||
// Up to 3 sample storage keys, ordered by page_number ASC.
|
||||
let samples_a = items[0]["sample_storage_keys"].as_array().unwrap();
|
||||
assert_eq!(samples_a.len(), 3);
|
||||
assert_eq!(items[1]["chapter_id"], chapter_b);
|
||||
assert_eq!(items[1]["match_count"], 1);
|
||||
assert_eq!(body["page"]["total"], 2);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn chapters_aggregate_respects_asc_order(pool: PgPool) {
|
||||
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 (chapter_a, pages_a) =
|
||||
seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 1, 3).await;
|
||||
let (chapter_b, pages_b) =
|
||||
seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 2, 1).await;
|
||||
for p in &pages_a {
|
||||
let _ = add_tag(&h.app, &cookie, p, "funny").await;
|
||||
}
|
||||
let _ = add_tag(&h.app, &cookie, &pages_b[0], "funny").await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
"/api/v1/me/page-tags/chapters?tag=funny&order=asc",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
let items = body["items"].as_array().unwrap();
|
||||
// Lowest count first.
|
||||
assert_eq!(items[0]["chapter_id"], chapter_b);
|
||||
assert_eq!(items[0]["match_count"], 1);
|
||||
assert_eq!(items[1]["chapter_id"], chapter_a);
|
||||
assert_eq!(items[1]["match_count"], 3);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn mangas_aggregate_sums_across_chapters(pool: PgPool) {
|
||||
// Two chapters in the same manga, each contributes to the same
|
||||
// manga's match_count.
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
||||
let (_, pages_a) =
|
||||
seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 1, 3).await;
|
||||
let (_, pages_b) =
|
||||
seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 2, 2).await;
|
||||
for p in pages_a.iter().chain(pages_b.iter()) {
|
||||
let _ = add_tag(&h.app, &cookie, p, "funny").await;
|
||||
}
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
"/api/v1/me/page-tags/mangas?tag=funny",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
let items = body["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["manga_id"], manga_id.to_string());
|
||||
assert_eq!(items[0]["match_count"], 5);
|
||||
assert_eq!(items[0]["manga_title"], "Berserk");
|
||||
let samples = items[0]["sample_storage_keys"].as_array().unwrap();
|
||||
assert_eq!(samples.len(), 3, "manga sample preview capped at 3");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn aggregate_other_users_tags_are_excluded(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, a) = common::register_user(&h.app).await;
|
||||
let (_, b) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &a, "M").await;
|
||||
let (_, pages) = seed_chapter_with_n_pages(&h.app, &a, manga_id, 1, 2).await;
|
||||
// A tags everything funny; B tags nothing.
|
||||
for p in &pages {
|
||||
let _ = add_tag(&h.app, &a, p, "funny").await;
|
||||
}
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
"/api/v1/me/page-tags/chapters?tag=funny",
|
||||
&b,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["items"], json!([]));
|
||||
assert_eq!(body["page"]["total"], 0);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn aggregate_unknown_tag_returns_empty_paged_response(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
"/api/v1/me/page-tags/chapters?tag=neverused",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["items"], json!([]));
|
||||
assert_eq!(body["page"]["total"], 0);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn aggregate_rejects_missing_tag(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
"/api/v1/me/page-tags/chapters?tag=",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn aggregate_rejects_invalid_order(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
"/api/v1/me/page-tags/chapters?tag=funny&order=sideways",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[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);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.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);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "text_search_not_yet_supported");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn aggregate_requires_authentication(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get("/api/v1/me/page-tags/chapters?tag=funny"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
Reference in New Issue
Block a user