import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { listChapters, getChapter, getChapterPages, createChapter } from './chapters'; function ok(body: unknown): Response { return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); } function envelope(status: number, code: string, message: string): Response { return new Response(JSON.stringify({ error: { code, message } }), { status, headers: { 'content-type': 'application/json' } }); } const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } }; const chapterFixture = { id: 'c1', manga_id: 'm1', number: 1, title: 'The Brand', page_count: 0, created_at: '2026-01-01T00:00:00Z' }; describe('chapters api client', () => { let fetchSpy: MockInstance; beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { vi.restoreAllMocks(); }); it('listChapters hits /v1/mangas/{id}/chapters with no params', async () => { fetchSpy.mockResolvedValueOnce(ok(emptyPage)); await listChapters('m1'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas\/m1\/chapters$/); }); it('listChapters encodes limit and offset', async () => { fetchSpy.mockResolvedValueOnce(ok(emptyPage)); await listChapters('m1', { limit: 10, offset: 20 }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain('limit=10'); expect(url).toContain('offset=20'); }); it('listChapters returns the paged envelope', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [chapterFixture], page: { limit: 50, offset: 0, total: null } }) ); const result = await listChapters('m1'); expect(result.items[0]).toEqual(chapterFixture); expect(result.page.total).toBeNull(); }); it('getChapter hits /v1/mangas/{id}/chapters/{chapter_id}', async () => { fetchSpy.mockResolvedValueOnce(ok(chapterFixture)); const c = await getChapter('m1', 'ch-uuid-1'); expect(c).toEqual(chapterFixture); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas\/m1\/chapters\/ch-uuid-1$/); }); it('getChapter surfaces 404 via ApiError.code', async () => { fetchSpy.mockResolvedValueOnce(envelope(404, 'not_found', 'not found')); await expect(getChapter('m1', 'unknown-uuid')).rejects.toMatchObject({ status: 404, code: 'not_found' }); }); it('createChapter POSTs multipart and renames page files to page-NNN.', async () => { fetchSpy.mockResolvedValueOnce(ok({ ...chapterFixture, page_count: 3 })); const pages = [ new File([new Uint8Array([1, 2])], 'IMG_2837.HEIC', { type: 'image/jpeg' }), new File([new Uint8Array([3, 4])], 'random.png', { type: 'image/png' }), // No extension; MIME-derived fallback should kick in. new File([new Uint8Array([5])], 'scan_42', { type: 'image/webp' }) ]; const result = await createChapter( 'm1', { number: 1, title: null }, pages ); expect(result.page_count).toBe(3); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas\/m1\/chapters$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); const form = init.body as FormData; // Metadata part is JSON. const metadata = form.get('metadata') as Blob; expect(metadata.type).toBe('application/json'); // Three pages, all renamed; original filenames discarded. const submitted = form.getAll('page') as File[]; expect(submitted).toHaveLength(3); // Original-extension preferred over MIME-derived; capitalised // .HEIC dropped because it's not in the allowed list, so the // MIME-derived `.jpg` wins. expect(submitted[0].name).toBe('page-001.jpg'); expect(submitted[1].name).toBe('page-002.png'); expect(submitted[2].name).toBe('page-003.webp'); // No original filenames leak through. for (const f of submitted) { expect(f.name).not.toMatch(/IMG_2837|random|scan_42/); } }); it('getChapterPages unwraps the {pages} envelope into the array', async () => { fetchSpy.mockResolvedValueOnce( ok({ pages: [ { id: 'p1', chapter_id: 'c1', page_number: 1, storage_key: 'mangas/m1/chapters/c1/pages/0001.png', content_type: 'image/png' } ] }) ); const pages = await getChapterPages('m1', 'ch-uuid-1'); expect(pages).toHaveLength(1); expect(pages[0].storage_key).toContain('0001.png'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas\/m1\/chapters\/ch-uuid-1\/pages$/); }); });