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>
159 lines
5.7 KiB
TypeScript
159 lines
5.7 KiB
TypeScript
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$/);
|
|
});
|
|
});
|