Files
Mangalord/backend/tests/api_collections.rs
MechaCat02 274cc819ca feat: manga collections (0.17.0)
User-owned named lists of mangas with an add-to-collection modal on
the manga page and dedicated /collections and /collections/:id pages.

- Schema (0010): `collections` (per-user case-insensitive name
  uniqueness) + `collection_mangas` join with cascade FKs.
- Endpoints: full CRUD on `/v1/collections`, idempotent add/remove
  for `/v1/collections/:id/mangas`, and `/v1/mangas/:id/my-collections`
  for the modal's pre-checked state. Owner-mismatch surfaces as 404
  (not 403) so the API doesn't disclose collection existence to
  non-owners; the frontend funnels 401 to /login. Three-state PATCH
  via a new shared `domain::patch::Patch<T>` lets clients distinguish
  "leave alone", "clear", and "set" for description.
- Frontend: reusable `Modal` component (focus trap, opt-in
  backdrop close, ESC) and `AddToCollectionModal` with optimistic
  toggling that's race-safe under fast clicks. /collections page
  renders cover-collage cards; /collections/:id is editable with
  per-card remove. Top nav gets a Collections link.

155 backend tests (incl. 21 collection tests covering ownership,
idempotence, sample-cover enrichment, three-state PATCH, FK race);
88 frontend tests; svelte-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:43:06 +02:00

606 lines
20 KiB
Rust

mod common;
use axum::http::StatusCode;
use serde_json::{json, Value};
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;
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, "create_collection failed");
common::body_json(resp).await
}
fn id_of(v: &Value) -> String {
v["id"].as_str().unwrap().to_string()
}
#[sqlx::test(migrations = "./migrations")]
async fn create_then_list_returns_only_own(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie_a) = common::register_user(&h.app).await;
let (_, cookie_b) = common::register_user(&h.app).await;
let _favs = create_collection(&h.app, &cookie_a, "Favorites").await;
let _read = create_collection(&h.app, &cookie_a, "Reading List").await;
// User B sees an empty list.
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie("/api/v1/me/collections", &cookie_b))
.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);
// User A sees both.
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/collections", &cookie_a))
.await
.unwrap();
let body = common::body_json(resp).await;
let names: Vec<&str> = body["items"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
// Newest-updated first; both rows have the same updated_at on
// create so we just sanity-check membership.
assert_eq!(names.len(), 2);
assert!(names.contains(&"Favorites"));
assert!(names.contains(&"Reading List"));
// Empty collections render with manga_count 0 and an empty
// sample_covers array, not `null`.
for item in body["items"].as_array().unwrap() {
assert_eq!(item["manga_count"], 0);
assert_eq!(item["sample_covers"], json!([]));
}
}
#[sqlx::test(migrations = "./migrations")]
async fn duplicate_name_for_same_user_is_409(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let _ = create_collection(&h.app, &cookie, "Favorites").await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
"/api/v1/collections",
json!({ "name": "favorites" }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "conflict");
}
#[sqlx::test(migrations = "./migrations")]
async fn two_users_can_share_a_collection_name(pool: PgPool) {
let h = common::harness(pool);
let (_, a) = common::register_user(&h.app).await;
let (_, b) = common::register_user(&h.app).await;
let _ = create_collection(&h.app, &a, "Favorites").await;
// No conflict — uniqueness is per-(user_id, lower(name)).
let _ = create_collection(&h.app, &b, "Favorites").await;
}
#[sqlx::test(migrations = "./migrations")]
async fn create_requires_authentication(pool: PgPool) {
let h = common::harness(pool);
let resp = h
.app
.oneshot(common::post_json(
"/api/v1/collections",
json!({ "name": "Anon" }),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "./migrations")]
async fn create_rejects_blank_name_with_422(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(
"/api/v1/collections",
json!({ "name": " " }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_one_returns_404_for_non_owner_no_existence_leak(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, "Favorites").await;
let id = id_of(&coll);
// Owner-mismatch is collapsed to 404 so the API doesn't disclose
// collection existence to non-owners. Otherwise an attacker could
// distinguish "exists, not yours" from "doesn't exist" by status.
let resp = h
.app
.oneshot(common::get_with_cookie(
&format!("/api/v1/collections/{id}"),
&b,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn add_manga_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, "Berserk").await;
let coll = create_collection(&h.app, &cookie, "Favorites").await;
let coll_id = id_of(&coll);
let req = || {
common::post_json_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas"),
json!({ "manga_id": manga_id.to_string() }),
&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_manga_returns_404_when_manga_missing(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let coll = create_collection(&h.app, &cookie, "Favorites").await;
let coll_id = id_of(&coll);
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas"),
json!({ "manga_id": Uuid::new_v4().to_string() }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn add_manga_to_someone_elses_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_a = create_collection(&h.app, &a, "Mine").await;
let coll_a_id = id_of(&coll_a);
let manga_id = common::seed_manga_via_api(&h.app, &b, "Anything").await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/collections/{coll_a_id}/mangas"),
json!({ "manga_id": manga_id.to_string() }),
&b,
))
.await
.unwrap();
// 404 not 403 — same non-existence-leak rationale as `get_one`.
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_on_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 coll = create_collection(&h.app, &a, "Mine").await;
let id = id_of(&coll);
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "name": "Hijacked" }),
&b,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_description_null_clears_existing_value(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 id = id_of(&coll);
// Seed a description first via PATCH.
let _ = h
.app
.clone()
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "description": "starting desc" }),
&cookie,
))
.await
.unwrap();
// Now PATCH with description=null and expect the column cleared.
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "description": null }),
&cookie,
))
.await
.unwrap();
let body = common::body_json(resp).await;
assert!(body["description"].is_null(), "expected description cleared");
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_description_empty_string_sets_empty_not_null(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 id = id_of(&coll);
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "description": "" }),
&cookie,
))
.await
.unwrap();
let body = common::body_json(resp).await;
// Empty string is a valid distinct value; only `null` clears.
assert_eq!(body["description"], "");
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_description_omitted_leaves_value_intact(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 id = id_of(&coll);
let _ = h
.app
.clone()
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "description": "Keep me" }),
&cookie,
))
.await
.unwrap();
// PATCH that doesn't mention description must not touch it.
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "name": "Renamed" }),
&cookie,
))
.await
.unwrap();
let body = common::body_json(resp).await;
assert_eq!(body["name"], "Renamed");
assert_eq!(body["description"], "Keep me");
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_with_empty_body_leaves_row_unchanged(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let coll = create_collection(&h.app, &cookie, "Stable").await;
let id = id_of(&coll);
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["name"], "Stable");
}
#[sqlx::test(migrations = "./migrations")]
async fn my_collections_for_unknown_manga_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/mangas/{}/my-collections", Uuid::new_v4()),
&cookie,
))
.await
.unwrap();
// Non-existent manga is treated the same as a manga the user
// hasn't collected — empty list. The handler comment documents
// this; the test pins it.
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 list_mangas_returns_collection_contents(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let m1 = common::seed_manga_via_api(&h.app, &cookie, "First").await;
let m2 = common::seed_manga_via_api(&h.app, &cookie, "Second").await;
let _untagged = common::seed_manga_via_api(&h.app, &cookie, "NotInIt").await;
let coll = create_collection(&h.app, &cookie, "Mix").await;
let coll_id = id_of(&coll);
for m in [m1, m2] {
let r = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas"),
json!({ "manga_id": m.to_string() }),
&cookie,
))
.await
.unwrap();
assert_eq!(r.status(), StatusCode::CREATED);
}
let resp = h
.app
.oneshot(common::get_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas"),
&cookie,
))
.await
.unwrap();
let body = common::body_json(resp).await;
let titles: Vec<&str> = body["items"]
.as_array()
.unwrap()
.iter()
.map(|m| m["title"].as_str().unwrap())
.collect();
// Newest-added first.
assert_eq!(titles, vec!["Second", "First"]);
assert_eq!(body["page"]["total"], 2);
}
#[sqlx::test(migrations = "./migrations")]
async fn remove_manga_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 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}/mangas"),
json!({ "manga_id": manga_id.to_string() }),
&cookie,
))
.await
.unwrap();
let first = h
.app
.clone()
.oneshot(common::delete_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas/{manga_id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::NO_CONTENT);
// Removing again is still a 204 — DELETE is idempotent.
let second = h
.app
.oneshot(common::delete_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas/{manga_id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::NO_CONTENT);
}
#[sqlx::test(migrations = "./migrations")]
async fn my_collections_for_manga_lists_only_owned_containing(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, "X").await;
let a_coll = create_collection(&h.app, &a, "A's").await;
let b_coll = create_collection(&h.app, &b, "B's").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}/mangas"),
json!({ "manga_id": manga_id.to_string() }),
cookie,
))
.await
.unwrap();
}
let resp = h
.app
.oneshot(common::get_with_cookie(
&format!("/api/v1/mangas/{manga_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 patch_collection_updates_name_and_description(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let coll = create_collection(&h.app, &cookie, "Old name").await;
let id = id_of(&coll);
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "name": "New name", "description": "Some notes" }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["name"], "New name");
assert_eq!(body["description"], "Some notes");
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_collection_cascades_attachments(pool: PgPool) {
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 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}/mangas"),
json!({ "manga_id": manga_id.to_string() }),
&cookie,
))
.await
.unwrap();
let resp = h
.app
.oneshot(common::delete_with_cookie(
&format!("/api/v1/collections/{coll_id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let (count,): (i64,) =
sqlx::query_as("SELECT count(*) FROM collection_mangas WHERE collection_id = $1")
.bind(Uuid::parse_str(&coll_id).unwrap())
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 0, "collection_mangas should cascade-delete with the collection");
}
#[sqlx::test(migrations = "./migrations")]
async fn list_summary_carries_sample_covers_when_mangas_attached(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
// Seed a manga with a cover via the upload endpoint so the
// cover_image_path column gets populated.
let make_metadata = |title: &str| {
common::MultipartBuilder::new()
.add_json("metadata", json!({ "title": title }))
.add_file("cover", "cover.png", "image/png", &common::fake_png_bytes())
};
let resp = h
.app
.clone()
.oneshot(common::post_multipart_with_cookie(
"/api/v1/mangas",
make_metadata("With cover"),
&cookie,
))
.await
.unwrap();
let body = common::body_json(resp).await;
let manga_id = body["id"].as_str().unwrap().to_string();
let coll = create_collection(&h.app, &cookie, "Visual").await;
let coll_id = id_of(&coll);
let r = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas"),
json!({ "manga_id": manga_id }),
&cookie,
))
.await
.unwrap();
assert_eq!(r.status(), StatusCode::CREATED);
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/collections", &cookie))
.await
.unwrap();
let body = common::body_json(resp).await;
let item = &body["items"][0];
assert_eq!(item["manga_count"], 1);
let covers = item["sample_covers"].as_array().unwrap();
assert_eq!(covers.len(), 1);
assert!(covers[0]
.as_str()
.unwrap()
.starts_with(&format!("mangas/{manga_id}/cover")));
}