import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { listChapters, listAllChapters, getChapter, getChapterPages, createChapter, chapterLabel } 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/); } }); describe('chapterLabel', () => { it('returns the site title verbatim when present', () => { expect(chapterLabel({ number: 7, title: 'Ch.7 : Official' })).toBe( 'Ch.7 : Official' ); }); it('falls back to "Chapter {number}" when title is null', () => { expect(chapterLabel({ number: 3, title: null })).toBe('Chapter 3'); }); }); it('listAllChapters pages through the 200-row cap and returns every chapter', async () => { const mk = (n: number) => ({ ...chapterFixture, id: `c${n}`, number: n }); const first = Array.from({ length: 200 }, (_, i) => mk(i + 1)); const second = Array.from({ length: 50 }, (_, i) => mk(i + 201)); fetchSpy .mockResolvedValueOnce(ok({ items: first, page: { limit: 200, offset: 0, total: 250 } })) .mockResolvedValueOnce(ok({ items: second, page: { limit: 200, offset: 200, total: 250 } })); const all = await listAllChapters('m1'); expect(all).toHaveLength(250); expect(all[249].number).toBe(250); expect(fetchSpy.mock.calls[0][0]).toContain('offset=0'); expect(fetchSpy.mock.calls[1][0]).toContain('offset=200'); }); it('listAllChapters keeps paging when the API omits total (total: null)', async () => { // The real /chapters endpoint returns `total: null` on every page // (PagedResponse::new). A full 200-row first page must NOT be mistaken // for the end — otherwise the reader dead-ends past chapter 200. const mk = (n: number) => ({ ...chapterFixture, id: `c${n}`, number: n }); const first = Array.from({ length: 200 }, (_, i) => mk(i + 1)); const second = Array.from({ length: 50 }, (_, i) => mk(i + 201)); fetchSpy .mockResolvedValueOnce(ok({ items: first, page: { limit: 200, offset: 0, total: null } })) .mockResolvedValueOnce(ok({ items: second, page: { limit: 200, offset: 200, total: null } })); const all = await listAllChapters('m1'); expect(all).toHaveLength(250); expect(all[249].number).toBe(250); expect(fetchSpy).toHaveBeenCalledTimes(2); }); it('listAllChapters stops after a short final page (single request)', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [chapterFixture], page: { limit: 200, offset: 0, total: 1 } }) ); const all = await listAllChapters('m1'); expect(all).toHaveLength(1); expect(fetchSpy).toHaveBeenCalledTimes(1); }); 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$/); }); });