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>
332 lines
11 KiB
Rust
332 lines
11 KiB
Rust
//! 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);
|
|
}
|