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>
This commit is contained in:
605
backend/tests/api_collections.rs
Normal file
605
backend/tests/api_collections.rs
Normal file
@@ -0,0 +1,605 @@
|
||||
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")));
|
||||
}
|
||||
Reference in New Issue
Block a user