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>
82 lines
2.3 KiB
Rust
82 lines
2.3 KiB
Rust
//! 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<T>`
|
|
//! patch field can't distinguish "leave alone" from "set to NULL".
|
|
//! `Patch<T>` 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<T> {
|
|
/// 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<T> Patch<T> {
|
|
/// 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<T>
|
|
where
|
|
T: serde::Deserialize<'de>,
|
|
{
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
Option::<T>::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<String>,
|
|
}
|
|
|
|
#[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()));
|
|
}
|
|
}
|