From 274cc819ca4f3ba9710daa7de2e59f0a8eefa6af Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 17 May 2026 17:43:06 +0200 Subject: [PATCH] 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` 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) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/migrations/0010_collections.sql | 31 + backend/src/api/collections.rs | 247 +++++++ backend/src/api/mangas.rs | 3 +- backend/src/api/mod.rs | 2 + backend/src/domain/collection.rs | 50 ++ backend/src/domain/manga.rs | 83 +-- backend/src/domain/mod.rs | 4 + backend/src/domain/patch.rs | 81 +++ backend/src/repo/collection.rs | 280 ++++++++ backend/src/repo/mod.rs | 1 + backend/tests/api_collections.rs | 605 ++++++++++++++++++ frontend/package.json | 2 +- frontend/src/lib/api/collections.test.ts | 158 +++++ frontend/src/lib/api/collections.ts | 139 ++++ .../components/AddToCollectionModal.svelte | 279 ++++++++ frontend/src/lib/components/Modal.svelte | 221 +++++++ frontend/src/routes/+layout.svelte | 5 + frontend/src/routes/collections/+page.svelte | 158 +++++ frontend/src/routes/collections/+page.ts | 20 + .../src/routes/collections/[id]/+page.svelte | 313 +++++++++ frontend/src/routes/collections/[id]/+page.ts | 40 ++ frontend/src/routes/manga/[id]/+page.svelte | 63 +- 24 files changed, 2689 insertions(+), 100 deletions(-) create mode 100644 backend/migrations/0010_collections.sql create mode 100644 backend/src/api/collections.rs create mode 100644 backend/src/domain/collection.rs create mode 100644 backend/src/domain/patch.rs create mode 100644 backend/src/repo/collection.rs create mode 100644 backend/tests/api_collections.rs create mode 100644 frontend/src/lib/api/collections.test.ts create mode 100644 frontend/src/lib/api/collections.ts create mode 100644 frontend/src/lib/components/AddToCollectionModal.svelte create mode 100644 frontend/src/lib/components/Modal.svelte create mode 100644 frontend/src/routes/collections/+page.svelte create mode 100644 frontend/src/routes/collections/+page.ts create mode 100644 frontend/src/routes/collections/[id]/+page.svelte create mode 100644 frontend/src/routes/collections/[id]/+page.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0c582aa..69f5403 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "mangalord" -version = "0.16.0" +version = "0.17.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a2704cc..5800d70 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.16.0" +version = "0.17.0" edition = "2021" [lib] diff --git a/backend/migrations/0010_collections.sql b/backend/migrations/0010_collections.sql new file mode 100644 index 0000000..3706159 --- /dev/null +++ b/backend/migrations/0010_collections.sql @@ -0,0 +1,31 @@ +-- User-owned manga collections. Each user can curate any number of +-- named lists (e.g., "Favorites", "Reading list"); mangas can belong +-- to many collections of many users without restriction. + +CREATE TABLE collections ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + description text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Per-user case-insensitive name uniqueness so "Favorites" and +-- "favorites" don't both end up in someone's sidebar. +CREATE UNIQUE INDEX collections_user_name_lower_uniq + ON collections (user_id, lower(name)); + +CREATE INDEX collections_user_idx ON collections (user_id, created_at DESC); + +CREATE TABLE collection_mangas ( + collection_id uuid NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE, + added_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (collection_id, manga_id) +); + +-- Reverse lookup: which collections contain this manga? Used by the +-- "Add to collection" modal to pre-check the boxes for the user's +-- collections this manga is already in. +CREATE INDEX collection_mangas_manga_idx ON collection_mangas (manga_id); diff --git a/backend/src/api/collections.rs b/backend/src/api/collections.rs new file mode 100644 index 0000000..3f259b7 --- /dev/null +++ b/backend/src/api/collections.rs @@ -0,0 +1,247 @@ +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use uuid::Uuid; + +use crate::api::pagination::PagedResponse; +use crate::app::AppState; +use crate::auth::extractor::CurrentUser; +use crate::domain::collection::{ + Collection, CollectionPatch, CollectionSummary, NewCollection, +}; +use crate::domain::manga::Manga; +use crate::domain::patch::Patch; +use crate::error::{AppError, AppResult}; +use crate::repo; + +pub fn routes() -> Router { + Router::new() + .route("/collections", post(create)) + .route("/me/collections", get(list_mine)) + .route("/collections/:id", get(get_one).patch(update).delete(delete_one)) + .route("/collections/:id/mangas", get(list_mangas).post(add_manga)) + .route( + "/collections/:id/mangas/:manga_id", + delete(remove_manga), + ) + .route( + "/mangas/:id/my-collections", + get(list_my_collections_containing), + ) +} + +const MAX_NAME_LEN: usize = 64; +const MAX_DESCRIPTION_LEN: usize = 1024; +const DEFAULT_LIMIT: i64 = 50; + +#[derive(Debug, Deserialize)] +pub struct ListParams { + #[serde(default = "default_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +fn default_limit() -> i64 { + DEFAULT_LIMIT +} + +#[derive(Debug, Deserialize)] +pub struct AddMangaBody { + pub manga_id: Uuid, +} + +#[derive(Debug, Serialize)] +pub struct MangaCollectionIds { + pub collection_ids: Vec, +} + +fn validate_name(name: &str) -> AppResult<()> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(AppError::ValidationFailed { + message: "name is required".into(), + details: json!({ "name": "required" }), + }); + } + if trimmed.chars().count() > MAX_NAME_LEN { + return Err(AppError::ValidationFailed { + message: "name too long".into(), + details: json!({ "name": format!("max {MAX_NAME_LEN} characters") }), + }); + } + Ok(()) +} + +fn validate_description(desc: Option<&str>) -> AppResult<()> { + if let Some(d) = desc { + if d.chars().count() > MAX_DESCRIPTION_LEN { + return Err(AppError::ValidationFailed { + message: "description too long".into(), + details: json!({ "description": format!("max {MAX_DESCRIPTION_LEN} characters") }), + }); + } + } + Ok(()) +} + +async fn create( + State(state): State, + CurrentUser(user): CurrentUser, + Json(input): Json, +) -> AppResult<(StatusCode, Json)> { + validate_name(&input.name)?; + validate_description(input.description.as_deref())?; + let row = repo::collection::create( + &state.db, + user.id, + &input.name, + input.description.as_deref(), + ) + .await?; + Ok((StatusCode::CREATED, Json(row))) +} + +async fn list_mine( + State(state): State, + CurrentUser(user): CurrentUser, + Query(params): Query, +) -> AppResult>> { + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + let (items, total) = + repo::collection::list_for_user(&state.db, user.id, limit, offset).await?; + Ok(Json(PagedResponse::with_total(items, limit, offset, total))) +} + +async fn get_one( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, +) -> AppResult> { + let row = require_owner(&state, user.id, id).await?; + Ok(Json(row)) +} + +async fn update( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, + Json(patch): Json, +) -> AppResult> { + require_owner_id(&state, user.id, id).await?; + if let Some(ref n) = patch.name { + validate_name(n)?; + } + if let Patch::Set(ref d) = patch.description { + validate_description(Some(d.as_str()))?; + } + // Three-state semantics via `Patch`: omitted → Unchanged + // (column untouched), explicit `null` → Clear (NULL), value → Set. + let description_provided = patch.description.is_provided(); + let description_value: Option<&str> = match &patch.description { + Patch::Set(s) => Some(s.as_str()), + Patch::Clear | Patch::Unchanged => None, + }; + let updated = repo::collection::update( + &state.db, + id, + patch.name.as_deref(), + description_provided, + description_value, + ) + .await?; + Ok(Json(updated)) +} + +async fn delete_one( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, +) -> AppResult { + require_owner_id(&state, user.id, id).await?; + repo::collection::delete(&state.db, id).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn list_mangas( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, + Query(params): Query, +) -> AppResult>> { + require_owner_id(&state, user.id, id).await?; + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + let (items, total) = + repo::collection::list_mangas(&state.db, id, limit, offset).await?; + Ok(Json(PagedResponse::with_total(items, limit, offset, total))) +} + +async fn add_manga( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, + Json(body): Json, +) -> AppResult { + require_owner_id(&state, user.id, id).await?; + if !repo::manga::exists(&state.db, body.manga_id).await? { + return Err(AppError::NotFound); + } + let created = repo::collection::add_manga(&state.db, id, body.manga_id).await?; + Ok(if created { StatusCode::CREATED } else { StatusCode::OK }) +} + +async fn remove_manga( + State(state): State, + CurrentUser(user): CurrentUser, + Path((collection_id, manga_id)): Path<(Uuid, Uuid)>, +) -> AppResult { + require_owner_id(&state, user.id, collection_id).await?; + repo::collection::remove_manga(&state.db, collection_id, manga_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn list_my_collections_containing( + State(state): State, + CurrentUser(user): CurrentUser, + Path(manga_id): Path, +) -> AppResult> { + // No 404 if the manga doesn't exist — the empty list is the + // correct answer ("you have it in zero of your collections") and + // keeps the request side-effect-free. + let ids = + repo::collection::list_collections_containing(&state.db, user.id, manga_id).await?; + Ok(Json(MangaCollectionIds { collection_ids: ids })) +} + +/// Returns the row iff the caller owns it. Both "doesn't exist" and +/// "exists but belongs to someone else" surface as `NotFound` so the +/// API doesn't disclose collection existence to non-owners — the +/// frontend already does this funnelling for URLs, and consistency at +/// the API matters because the same identifiers travel through bots +/// and shared links. +async fn require_owner( + state: &AppState, + user_id: Uuid, + id: Uuid, +) -> AppResult { + match repo::collection::get(&state.db, id).await { + Ok(row) if row.user_id == user_id => Ok(row), + // Either the row doesn't exist (NotFound from `get`) or it + // belongs to someone else — both collapse to NotFound. + Ok(_) | Err(AppError::NotFound) => Err(AppError::NotFound), + Err(other) => Err(other), + } +} + +async fn require_owner_id(state: &AppState, user_id: Uuid, id: Uuid) -> AppResult<()> { + match repo::collection::find_owner(&state.db, id).await? { + Some(owner) if owner == user_id => Ok(()), + // Same non-leakage rationale as `require_owner` above. + _ => Err(AppError::NotFound), + } +} diff --git a/backend/src/api/mangas.rs b/backend/src/api/mangas.rs index d842760..5249500 100644 --- a/backend/src/api/mangas.rs +++ b/backend/src/api/mangas.rs @@ -9,7 +9,8 @@ use uuid::Uuid; use crate::api::pagination::PagedResponse; use crate::app::AppState; use crate::auth::extractor::CurrentUser; -use crate::domain::manga::{MangaCard, MangaDetail, MangaPatch, NewManga, Patch}; +use crate::domain::manga::{MangaCard, MangaDetail, MangaPatch, NewManga}; +use crate::domain::patch::Patch; use crate::domain::tag::TagRef; use crate::error::{AppError, AppResult}; use crate::repo; diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 5933b3c..c823e09 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod authors; pub mod bookmarks; pub mod chapters; +pub mod collections; pub mod files; pub mod genres; pub mod health; @@ -24,4 +25,5 @@ pub fn routes() -> Router { .merge(genres::routes()) .merge(tags::routes()) .merge(authors::routes()) + .merge(collections::routes()) } diff --git a/backend/src/domain/collection.rs b/backend/src/domain/collection.rs new file mode 100644 index 0000000..578fcb2 --- /dev/null +++ b/backend/src/domain/collection.rs @@ -0,0 +1,50 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +use super::patch::Patch; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Collection { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Shape returned by `GET /me/collections`. Enriched with the manga +/// count and up to three sample cover paths so a collection card can +/// render without extra round-trips. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct CollectionSummary { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub manga_count: i64, + /// Cover image keys of up to three sample mangas (newest-added + /// first). `Vec` rather than `Option<...>` so an empty + /// collection renders as `[]` rather than `null`. + pub sample_covers: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NewCollection { + pub name: String, + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct CollectionPatch { + pub name: Option, + /// Three-state: missing key leaves description alone; explicit + /// `null` clears it; a string sets it. See `Patch`. + #[serde(default)] + pub description: Patch, +} diff --git a/backend/src/domain/manga.rs b/backend/src/domain/manga.rs index fb6c3b6..a863a52 100644 --- a/backend/src/domain/manga.rs +++ b/backend/src/domain/manga.rs @@ -5,6 +5,7 @@ use uuid::Uuid; use super::author::AuthorRef; use super::genre::GenreRef; +use super::patch::Patch; use super::tag::TagRef; #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] @@ -73,82 +74,6 @@ pub struct MangaPatch { pub genre_ids: Option>, } -/// Three-state container for nullable PATCH fields. -/// -/// `serde`'s default behaviour collapses both "field missing" and -/// "field is `null`" to `Option::None`, which means an `Option` -/// patch field can't distinguish "leave alone" from "set to NULL". -/// `Patch` carries that distinction by deserializing JSON `null` -/// into `Clear` and any value into `Set`; with `#[serde(default)]` on -/// the field, a missing key falls through to `Unchanged`. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub enum Patch { - /// Field absent from the request — leave the column untouched. - #[default] - Unchanged, - /// Field present and explicitly `null` — set the column to NULL. - Clear, - /// Field present with a value — set the column to that value. - Set(T), -} - -impl Patch { - /// Whether the request indicated this field should be written - /// (either to a new value or to NULL). - pub fn is_provided(&self) -> bool { - !matches!(self, Patch::Unchanged) - } - - /// The value to bind when writing, or `None` for `Unchanged`/`Clear`. - pub fn set_value(&self) -> Option<&T> { - match self { - Patch::Set(v) => Some(v), - _ => None, - } - } -} - -impl<'de, T> serde::Deserialize<'de> for Patch -where - T: serde::Deserialize<'de>, -{ - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - Option::::deserialize(deserializer).map(|opt| match opt { - Some(v) => Patch::Set(v), - None => Patch::Clear, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[derive(Deserialize)] - struct Holder { - #[serde(default)] - desc: Patch, - } - - #[test] - fn missing_key_is_unchanged() { - let h: Holder = serde_json::from_value(json!({})).unwrap(); - assert_eq!(h.desc, Patch::Unchanged); - } - - #[test] - fn explicit_null_is_clear() { - let h: Holder = serde_json::from_value(json!({ "desc": null })).unwrap(); - assert_eq!(h.desc, Patch::Clear); - } - - #[test] - fn value_is_set() { - let h: Holder = serde_json::from_value(json!({ "desc": "x" })).unwrap(); - assert_eq!(h.desc, Patch::Set("x".into())); - } -} +// `Patch` lives in `super::patch` so other resources (collections, +// future PATCH endpoints) can reuse the same three-state semantics +// without re-importing through `manga::`. diff --git a/backend/src/domain/mod.rs b/backend/src/domain/mod.rs index 5945d96..2b7547a 100644 --- a/backend/src/domain/mod.rs +++ b/backend/src/domain/mod.rs @@ -2,9 +2,11 @@ pub mod api_token; pub mod author; pub mod bookmark; pub mod chapter; +pub mod collection; pub mod genre; pub mod manga; pub mod page; +pub mod patch; pub mod session; pub mod tag; pub mod user; @@ -14,9 +16,11 @@ pub use api_token::ApiToken; pub use author::{Author, AuthorRef, AuthorWithCount}; pub use bookmark::{Bookmark, BookmarkSummary}; pub use chapter::Chapter; +pub use collection::{Collection, CollectionSummary}; pub use genre::{Genre, GenreRef}; pub use manga::{Manga, MangaCard, MangaDetail}; pub use page::Page; +pub use patch::Patch; pub use session::Session; pub use tag::{Tag, TagRef}; pub use user::User; diff --git a/backend/src/domain/patch.rs b/backend/src/domain/patch.rs new file mode 100644 index 0000000..bdf596a --- /dev/null +++ b/backend/src/domain/patch.rs @@ -0,0 +1,81 @@ +//! Three-state container for PATCH fields. +//! +//! `serde`'s default behaviour collapses both "field missing" and +//! "field is `null`" to `Option::None`, which means an `Option` +//! patch field can't distinguish "leave alone" from "set to NULL". +//! `Patch` carries that distinction by deserializing JSON `null` +//! into `Clear` and any value into `Set`; with `#[serde(default)]` +//! on the field, a missing key falls through to `Unchanged`. + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum Patch { + /// Field absent from the request — leave the column untouched. + #[default] + Unchanged, + /// Field present and explicitly `null` — set the column to NULL. + Clear, + /// Field present with a value — set the column to that value. + Set(T), +} + +impl Patch { + /// Whether the request indicated this field should be written + /// (either to a new value or to NULL). + pub fn is_provided(&self) -> bool { + !matches!(self, Patch::Unchanged) + } + + /// The value to bind when writing, or `None` for `Unchanged`/`Clear`. + pub fn set_value(&self) -> Option<&T> { + match self { + Patch::Set(v) => Some(v), + _ => None, + } + } +} + +impl<'de, T> serde::Deserialize<'de> for Patch +where + T: serde::Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Option::::deserialize(deserializer).map(|opt| match opt { + Some(v) => Patch::Set(v), + None => Patch::Clear, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + use serde_json::json; + + #[derive(Deserialize)] + struct Holder { + #[serde(default)] + desc: Patch, + } + + #[test] + fn missing_key_is_unchanged() { + let h: Holder = serde_json::from_value(json!({})).unwrap(); + assert_eq!(h.desc, Patch::Unchanged); + } + + #[test] + fn explicit_null_is_clear() { + let h: Holder = serde_json::from_value(json!({ "desc": null })).unwrap(); + assert_eq!(h.desc, Patch::Clear); + } + + #[test] + fn value_is_set() { + let h: Holder = serde_json::from_value(json!({ "desc": "x" })).unwrap(); + assert_eq!(h.desc, Patch::Set("x".into())); + } +} diff --git a/backend/src/repo/collection.rs b/backend/src/repo/collection.rs new file mode 100644 index 0000000..f9b028c --- /dev/null +++ b/backend/src/repo/collection.rs @@ -0,0 +1,280 @@ +//! Collection persistence. +//! +//! Same plain-function pattern as `repo::bookmark`. Ownership is +//! tracked via `collections.user_id`; handlers call `find_owner` +//! before mutations to keep 403/404 honest. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::domain::collection::{Collection, CollectionSummary}; +use crate::domain::manga::Manga; +use crate::error::{AppError, AppResult}; + +pub async fn create( + pool: &PgPool, + user_id: Uuid, + name: &str, + description: Option<&str>, +) -> AppResult { + let row = sqlx::query_as::<_, Collection>( + r#" + INSERT INTO collections (user_id, name, description) + VALUES ($1, $2, $3) + RETURNING id, user_id, name, description, created_at, updated_at + "#, + ) + .bind(user_id) + .bind(name.trim()) + .bind(description) + .fetch_one(pool) + .await + .map_err(|e| match e { + sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => { + AppError::Conflict("a collection with this name already exists".into()) + } + other => AppError::Database(other), + })?; + Ok(row) +} + +pub async fn get(pool: &PgPool, id: Uuid) -> AppResult { + sqlx::query_as::<_, Collection>( + r#" + SELECT id, user_id, name, description, created_at, updated_at + FROM collections + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await? + .ok_or(AppError::NotFound) +} + +pub async fn find_owner(pool: &PgPool, id: Uuid) -> AppResult> { + let row: Option<(Uuid,)> = + sqlx::query_as("SELECT user_id FROM collections WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?; + Ok(row.map(|(u,)| u)) +} + +/// Paged list of one user's collections. Includes `manga_count` and up +/// to three sample cover image keys (newest-added first) so a card can +/// render without a follow-up fetch. +pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, + limit: i64, + offset: i64, +) -> AppResult<(Vec, i64)> { + let rows = sqlx::query_as::<_, CollectionSummary>( + r#" + SELECT + c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at, + (SELECT count(*) FROM collection_mangas cm WHERE cm.collection_id = c.id) + AS manga_count, + COALESCE( + ( + -- `array_agg(... ORDER BY ...)` is the only + -- spec-guaranteed way to preserve element order; + -- a subquery's ORDER BY isn't a contract the + -- outer aggregate has to honour. Adding manga_id + -- as a tiebreaker keeps the order stable when + -- multiple rows share `added_at` (bulk imports). + SELECT array_agg(cover_image_path ORDER BY added_at DESC, manga_id) + FROM ( + SELECT m.cover_image_path, cm2.added_at, cm2.manga_id + FROM collection_mangas cm2 + JOIN mangas m ON m.id = cm2.manga_id + WHERE cm2.collection_id = c.id + AND m.cover_image_path IS NOT NULL + ORDER BY cm2.added_at DESC, cm2.manga_id + LIMIT 3 + ) p + ), + ARRAY[]::text[] + ) AS sample_covers + FROM collections c + WHERE c.user_id = $1 + ORDER BY c.updated_at DESC, c.id + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + let (total,): (i64,) = + sqlx::query_as("SELECT count(*) FROM collections WHERE user_id = $1") + .bind(user_id) + .fetch_one(pool) + .await?; + Ok((rows, total)) +} + +pub async fn update( + pool: &PgPool, + id: Uuid, + name: Option<&str>, + description_provided: bool, + description: Option<&str>, +) -> AppResult { + let row = sqlx::query_as::<_, Collection>( + r#" + UPDATE collections + SET name = COALESCE($2, name), + description = CASE WHEN $3::boolean THEN $4 ELSE description END, + updated_at = now() + WHERE id = $1 + RETURNING id, user_id, name, description, created_at, updated_at + "#, + ) + .bind(id) + .bind(name.map(str::trim)) + .bind(description_provided) + .bind(description) + .fetch_optional(pool) + .await + .map_err(|e| match e { + sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => { + AppError::Conflict("a collection with this name already exists".into()) + } + other => AppError::Database(other), + })? + .ok_or(AppError::NotFound)?; + Ok(row) +} + +pub async fn delete(pool: &PgPool, id: Uuid) -> AppResult<()> { + sqlx::query("DELETE FROM collections WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +/// Add a manga to a collection. Returns `true` if a new attachment was +/// created (handler picks 201), `false` if the manga was already in +/// the collection (handler picks 200). Touches `updated_at` so the +/// "recent collections" sort reflects activity. +/// +/// FK violations (manga deleted between the handler's `exists` check +/// and this insert — a race the API can't fully close from the +/// outside) are remapped to `NotFound` so the handler returns 404 +/// rather than 500. +pub async fn add_manga( + pool: &PgPool, + collection_id: Uuid, + manga_id: Uuid, +) -> AppResult { + let mut tx = pool.begin().await?; + let inserted = sqlx::query( + r#" + INSERT INTO collection_mangas (collection_id, manga_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + "#, + ) + .bind(collection_id) + .bind(manga_id) + .execute(&mut *tx) + .await + .map_err(|e| match e { + sqlx::Error::Database(ref db_err) if db_err.is_foreign_key_violation() => { + AppError::NotFound + } + other => AppError::Database(other), + })?; + let rows_affected = inserted.rows_affected(); + if rows_affected > 0 { + sqlx::query("UPDATE collections SET updated_at = now() WHERE id = $1") + .bind(collection_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(rows_affected > 0) +} + +pub async fn remove_manga( + pool: &PgPool, + collection_id: Uuid, + manga_id: Uuid, +) -> AppResult<()> { + let mut tx = pool.begin().await?; + let rows_affected = sqlx::query( + "DELETE FROM collection_mangas WHERE collection_id = $1 AND manga_id = $2", + ) + .bind(collection_id) + .bind(manga_id) + .execute(&mut *tx) + .await? + .rows_affected(); + if rows_affected > 0 { + sqlx::query("UPDATE collections SET updated_at = now() WHERE id = $1") + .bind(collection_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} + +pub async fn list_mangas( + pool: &PgPool, + collection_id: Uuid, + limit: i64, + offset: i64, +) -> AppResult<(Vec, i64)> { + let rows = sqlx::query_as::<_, Manga>( + r#" + SELECT m.id, m.title, m.status, m.alt_titles, m.description, + m.cover_image_path, m.created_at, m.updated_at + FROM collection_mangas cm + JOIN mangas m ON m.id = cm.manga_id + WHERE cm.collection_id = $1 + ORDER BY cm.added_at DESC, m.id + LIMIT $2 OFFSET $3 + "#, + ) + .bind(collection_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + let (total,): (i64,) = + sqlx::query_as("SELECT count(*) FROM collection_mangas WHERE collection_id = $1") + .bind(collection_id) + .fetch_one(pool) + .await?; + Ok((rows, total)) +} + +/// Which of `user_id`'s collections currently contain `manga_id`? +/// Used by the "Add to collection" modal to pre-check the boxes. +pub async fn list_collections_containing( + pool: &PgPool, + user_id: Uuid, + manga_id: Uuid, +) -> AppResult> { + let rows: Vec<(Uuid,)> = sqlx::query_as( + r#" + SELECT c.id + FROM collections c + JOIN collection_mangas cm ON cm.collection_id = c.id + WHERE c.user_id = $1 + AND cm.manga_id = $2 + ORDER BY c.updated_at DESC + "#, + ) + .bind(user_id) + .bind(manga_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|(id,)| id).collect()) +} diff --git a/backend/src/repo/mod.rs b/backend/src/repo/mod.rs index 9f25ee5..0d366bb 100644 --- a/backend/src/repo/mod.rs +++ b/backend/src/repo/mod.rs @@ -2,6 +2,7 @@ pub mod api_token; pub mod author; pub mod bookmark; pub mod chapter; +pub mod collection; pub mod genre; pub mod manga; pub mod page; diff --git a/backend/tests/api_collections.rs b/backend/tests/api_collections.rs new file mode 100644 index 0000000..6b5fe11 --- /dev/null +++ b/backend/tests/api_collections.rs @@ -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"))); +} diff --git a/frontend/package.json b/frontend/package.json index 1447949..aaacc52 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.16.0", + "version": "0.17.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/collections.test.ts b/frontend/src/lib/api/collections.test.ts new file mode 100644 index 0000000..3368422 --- /dev/null +++ b/frontend/src/lib/api/collections.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; +import { + listMyCollections, + listMyCollectionsOrEmpty, + createCollection, + getCollection, + updateCollection, + deleteCollection, + listCollectionMangas, + addMangaToCollection, + removeMangaFromCollection, + getMyCollectionsContaining +} from './collections'; + +function ok(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +function noContent(): Response { + return new Response(null, { status: 204 }); +} + +function envelope(status: number, code: string, message: string): Response { + return new Response(JSON.stringify({ error: { code, message } }), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +function collectionFixture(extra: Record = {}) { + return { + id: 'c1', + user_id: 'u1', + name: 'Favorites', + description: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + manga_count: 0, + sample_covers: [], + ...extra + }; +} + +describe('collections api client', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('listMyCollections returns the paged envelope', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + items: [collectionFixture()], + page: { limit: 50, offset: 0, total: 1 } + }) + ); + const result = await listMyCollections(); + expect(result.items[0].name).toBe('Favorites'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/me\/collections$/); + }); + + it('listMyCollectionsOrEmpty returns empty page on 401', async () => { + fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login required')); + const result = await listMyCollectionsOrEmpty(); + expect(result.items).toEqual([]); + expect(result.page.total).toBeNull(); + }); + + it('listMyCollectionsOrEmpty re-throws non-401 errors', async () => { + fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'oops')); + await expect(listMyCollectionsOrEmpty()).rejects.toMatchObject({ status: 500 }); + }); + + it('createCollection POSTs JSON to /v1/collections', async () => { + fetchSpy.mockResolvedValueOnce(ok(collectionFixture(), 201)); + const c = await createCollection({ name: 'Favorites' }); + expect(c.name).toBe('Favorites'); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ name: 'Favorites' }); + }); + + it('getCollection encodes the id', async () => { + fetchSpy.mockResolvedValueOnce(ok(collectionFixture())); + await getCollection('id with space'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('/v1/collections/id%20with%20space'); + }); + + it('updateCollection PATCHes with the patch body', async () => { + fetchSpy.mockResolvedValueOnce(ok(collectionFixture({ name: 'Read later' }))); + const updated = await updateCollection('c1', { name: 'Read later' }); + expect(updated.name).toBe('Read later'); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('PATCH'); + expect(JSON.parse(init.body as string)).toEqual({ name: 'Read later' }); + }); + + it('deleteCollection issues DELETE', async () => { + fetchSpy.mockResolvedValueOnce(noContent()); + await deleteCollection('c1'); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('DELETE'); + }); + + it('listCollectionMangas returns the paged envelope of mangas', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + items: [ + { + id: 'm1', + title: 'Berserk', + status: 'ongoing', + alt_titles: [], + description: null, + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + } + ], + page: { limit: 50, offset: 0, total: 1 } + }) + ); + const r = await listCollectionMangas('c1'); + expect(r.items[0].title).toBe('Berserk'); + }); + + it('addMangaToCollection POSTs the manga_id', async () => { + fetchSpy.mockResolvedValueOnce(ok({}, 201)); + await addMangaToCollection('c1', 'm9'); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ manga_id: 'm9' }); + }); + + it('removeMangaFromCollection DELETEs the nested resource', async () => { + fetchSpy.mockResolvedValueOnce(noContent()); + await removeMangaFromCollection('c1', 'm9'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/collections\/c1\/mangas\/m9$/); + }); + + it('getMyCollectionsContaining returns the id list', async () => { + fetchSpy.mockResolvedValueOnce(ok({ collection_ids: ['c1', 'c3'] })); + const ids = await getMyCollectionsContaining('m1'); + expect(ids).toEqual(['c1', 'c3']); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/mangas\/m1\/my-collections$/); + }); +}); diff --git a/frontend/src/lib/api/collections.ts b/frontend/src/lib/api/collections.ts new file mode 100644 index 0000000..12ad60d --- /dev/null +++ b/frontend/src/lib/api/collections.ts @@ -0,0 +1,139 @@ +import { ApiError, request, type Manga, type Page } from './client'; + +export type Collection = { + id: string; + user_id: string; + name: string; + description: string | null; + created_at: string; + updated_at: string; +}; + +/** Returned by `GET /v1/me/collections` — enriched for card rendering. */ +export type CollectionSummary = Collection & { + manga_count: number; + /** Up to 3 cover image keys, newest-added first. */ + sample_covers: string[]; +}; + +export type CollectionsPage = { + items: CollectionSummary[]; + page: Page; +}; + +export type CollectionMangasPage = { + items: Manga[]; + page: Page; +}; + +export type NewCollection = { + name: string; + description?: string | null; +}; + +export type CollectionPatch = { + name?: string; + description?: string | null; +}; + +export type ListMyOptions = { limit?: number; offset?: number }; + +export async function listMyCollections( + opts: ListMyOptions = {} +): Promise { + const params = new URLSearchParams(); + if (opts.limit != null) params.set('limit', String(opts.limit)); + if (opts.offset != null) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return request(`/v1/me/collections${qs ? `?${qs}` : ''}`); +} + +/** Empty page on 401 so guest-rendering pages don't have to special-case. */ +export async function listMyCollectionsOrEmpty(): Promise { + try { + return await listMyCollections(); + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + return { items: [], page: { limit: 50, offset: 0, total: null } }; + } + throw e; + } +} + +export async function createCollection( + input: NewCollection +): Promise { + return request('/v1/collections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input) + }); +} + +export async function getCollection(id: string): Promise { + return request(`/v1/collections/${encodeURIComponent(id)}`); +} + +export async function updateCollection( + id: string, + patch: CollectionPatch +): Promise { + return request(`/v1/collections/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch) + }); +} + +export async function deleteCollection(id: string): Promise { + await request(`/v1/collections/${encodeURIComponent(id)}`, { + method: 'DELETE' + }); +} + +export async function listCollectionMangas( + id: string, + opts: ListMyOptions = {} +): Promise { + const params = new URLSearchParams(); + if (opts.limit != null) params.set('limit', String(opts.limit)); + if (opts.offset != null) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return request( + `/v1/collections/${encodeURIComponent(id)}/mangas${qs ? `?${qs}` : ''}` + ); +} + +export async function addMangaToCollection( + collectionId: string, + mangaId: string +): Promise { + await request( + `/v1/collections/${encodeURIComponent(collectionId)}/mangas`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ manga_id: mangaId }) + } + ); +} + +export async function removeMangaFromCollection( + collectionId: string, + mangaId: string +): Promise { + await request( + `/v1/collections/${encodeURIComponent(collectionId)}/mangas/${encodeURIComponent(mangaId)}`, + { method: 'DELETE' } + ); +} + +/** Which of the user's collections currently contain this manga. */ +export async function getMyCollectionsContaining( + mangaId: string +): Promise { + const r = await request<{ collection_ids: string[] }>( + `/v1/mangas/${encodeURIComponent(mangaId)}/my-collections` + ); + return r.collection_ids; +} diff --git a/frontend/src/lib/components/AddToCollectionModal.svelte b/frontend/src/lib/components/AddToCollectionModal.svelte new file mode 100644 index 0000000..e830316 --- /dev/null +++ b/frontend/src/lib/components/AddToCollectionModal.svelte @@ -0,0 +1,279 @@ + + + + {#if loading} +

Loading your collections…

+ {:else if error} + + {:else if collections.length === 0} +

+ You don't have any collections yet. Create one below to get started. +

+ {:else} +
    + {#each collections as c (c.id)} + {@const checked = containingIds.has(c.id)} + {@const busy = busyIds.has(c.id)} +
  • + +
  • + {/each} +
+ {/if} + +
+ + +
+
+ + diff --git a/frontend/src/lib/components/Modal.svelte b/frontend/src/lib/components/Modal.svelte new file mode 100644 index 0000000..ddb3624 --- /dev/null +++ b/frontend/src/lib/components/Modal.svelte @@ -0,0 +1,221 @@ + + +{#if open} + +{/if} + + diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 4970be3..3ccd209 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -7,6 +7,7 @@ import { theme } from '$lib/theme.svelte'; import Upload from '@lucide/svelte/icons/upload'; import Bookmark from '@lucide/svelte/icons/bookmark'; + import FolderOpen from '@lucide/svelte/icons/folder-open'; import Settings from '@lucide/svelte/icons/settings'; import LogOut from '@lucide/svelte/icons/log-out'; import '$lib/styles/tokens.css'; @@ -56,6 +57,10 @@