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$/); }); });