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:
158
frontend/src/lib/api/collections.test.ts
Normal file
158
frontend/src/lib/api/collections.test.ts
Normal file
@@ -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<string, unknown> = {}) {
|
||||
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<typeof globalThis.fetch>;
|
||||
|
||||
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$/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user