import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { listMangas, createManga, getManga, updateManga, updateMangaCover, deleteMangaCover, attachTag, detachTag } from './mangas'; 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 emptyPage() { return { items: [], page: { limit: 50, offset: 0, total: null } }; } function cardFixture(extra: Record = {}) { return { id: 'b1', 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', authors: [{ id: 'a1', name: 'Kentaro Miura' }], genres: [], ...extra }; } function detailFixture(extra: Record = {}) { return { ...cardFixture(), tags: [], ...extra }; } describe('mangas api client', () => { let fetchSpy: MockInstance; beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { vi.restoreAllMocks(); }); it('listMangas hits /v1/mangas with no params by default', async () => { fetchSpy.mockResolvedValueOnce(ok(emptyPage())); await listMangas(); expect(fetchSpy).toHaveBeenCalledTimes(1); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas$/); }); it('listMangas returns the paged envelope with cards', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [cardFixture()], page: { limit: 50, offset: 0, total: 1 } }) ); const result = await listMangas(); expect(result.items).toHaveLength(1); expect(result.items[0].title).toBe('Berserk'); expect(result.items[0].authors[0].name).toBe('Kentaro Miura'); expect(result.page).toEqual({ limit: 50, offset: 0, total: 1 }); }); it('listMangas encodes search, status, ids (csv), limit, offset, sort', async () => { fetchSpy.mockResolvedValueOnce(ok(emptyPage())); await listMangas({ search: 'one piece', status: 'completed', authorIds: ['a1', 'a2'], genreIds: ['g1'], tagIds: ['t1', 't2'], limit: 10, offset: 20, sort: 'title' }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas\?/); expect(url).toContain('search=one+piece'); expect(url).toContain('status=completed'); // Multi-value facets land as a single comma-separated param so the // backend can apply pure AND semantics across the list. expect(url).toContain('author_id=a1%2Ca2'); expect(url).toContain('genre_id=g1'); expect(url).toContain('tag_id=t1%2Ct2'); expect(url).toContain('limit=10'); expect(url).toContain('offset=20'); expect(url).toContain('sort=title'); }); it('getManga returns the enriched detail shape', async () => { fetchSpy.mockResolvedValueOnce( ok(detailFixture({ tags: [{ id: 't1', name: 'Seinen', added_by: 'u1' }] })) ); const m = await getManga('b1'); expect(m.tags).toHaveLength(1); expect(m.tags[0].name).toBe('Seinen'); expect(m.authors[0].name).toBe('Kentaro Miura'); }); it('createManga POSTs multipart with the new metadata shape', async () => { fetchSpy.mockResolvedValueOnce(ok(detailFixture())); await createManga({ title: 'Berserk', authors: ['Kentaro Miura'], status: 'completed', alt_titles: ['ベルセルク'], genre_ids: ['g1'] }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); expect(init.body).toBeInstanceOf(FormData); const form = init.body as FormData; const metadata = form.get('metadata') as Blob; expect(metadata).toBeInstanceOf(Blob); expect(metadata.type).toBe('application/json'); const text = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.readAsText(metadata); }); const parsed = JSON.parse(text); expect(parsed.title).toBe('Berserk'); expect(parsed.authors).toEqual(['Kentaro Miura']); expect(parsed.status).toBe('completed'); expect(parsed.alt_titles).toEqual(['ベルセルク']); expect(parsed.genre_ids).toEqual(['g1']); expect(form.get('cover')).toBeNull(); // The browser sets Content-Type with boundary automatically when body // is a FormData — we must NOT set it ourselves. expect(init.headers).toBeUndefined(); }); it('createManga attaches the cover Blob when supplied', async () => { fetchSpy.mockResolvedValueOnce(ok(detailFixture())); const cover = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' }); await createManga({ title: 'Berserk' }, cover); const init = fetchSpy.mock.calls[0][1] as RequestInit; const form = init.body as FormData; const got = form.get('cover'); expect(got).toBeInstanceOf(Blob); }); it('updateManga PATCHes with the provided patch body', async () => { fetchSpy.mockResolvedValueOnce(ok(detailFixture({ status: 'completed' }))); const updated = await updateManga('b1', { status: 'completed', authors: ['Kentaro Miura', 'Studio Gaga'] }); expect(updated.status).toBe('completed'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas\/b1$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('PATCH'); expect(JSON.parse(init.body as string)).toEqual({ status: 'completed', authors: ['Kentaro Miura', 'Studio Gaga'] }); }); it('updateMangaCover PUTs multipart with the cover blob', async () => { fetchSpy.mockResolvedValueOnce( ok(detailFixture({ cover_image_path: 'mangas/b1/cover.png' })) ); const cover = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' }); const updated = await updateMangaCover('b1', cover); expect(updated.cover_image_path).toBe('mangas/b1/cover.png'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas\/b1\/cover$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('PUT'); expect(init.body).toBeInstanceOf(FormData); const form = init.body as FormData; expect(form.get('cover')).toBeInstanceOf(Blob); // Boundary is filled in by the browser when body is FormData. expect(init.headers).toBeUndefined(); }); it('updateMangaCover throws ApiError on payload_too_large', async () => { fetchSpy.mockResolvedValue( envelope(413, 'payload_too_large', 'cover exceeds size cap') ); const cover = new Blob([new Uint8Array(1)]); await expect(updateMangaCover('b1', cover)).rejects.toMatchObject({ name: 'ApiError', status: 413, code: 'payload_too_large' }); }); it('deleteMangaCover DELETEs and returns the refreshed detail with null path', async () => { fetchSpy.mockResolvedValueOnce( ok(detailFixture({ cover_image_path: null })) ); const updated = await deleteMangaCover('b1'); expect(updated.cover_image_path).toBeNull(); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas\/b1\/cover$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('DELETE'); expect(init.body).toBeUndefined(); }); it('attachTag POSTs the name and returns the TagRef', async () => { fetchSpy.mockResolvedValueOnce( ok({ id: 't9', name: 'Dark Fantasy', added_by: 'u1' }, 201) ); const tag = await attachTag('b1', 'Dark Fantasy'); expect(tag.name).toBe('Dark Fantasy'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas\/b1\/tags$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); expect(JSON.parse(init.body as string)).toEqual({ name: 'Dark Fantasy' }); }); it('detachTag DELETEs the (manga, tag) pair', async () => { fetchSpy.mockResolvedValueOnce(noContent()); await detachTag('b1', 't9'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas\/b1\/tags\/t9$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('DELETE'); }); it('getManga throws ApiError carrying the envelope code on non-2xx', async () => { fetchSpy.mockResolvedValue(envelope(404, 'not_found', 'manga not found')); await expect(getManga('missing')).rejects.toMatchObject({ name: 'ApiError', status: 404, code: 'not_found', message: 'manga not found' }); }); });