Files
Mangalord/frontend/src/lib/api/chapters.test.ts
MechaCat02 a44511983d
All checks were successful
deploy / test-backend (push) Successful in 44m20s
deploy / test-frontend (push) Successful in 11m3s
deploy / build-and-push (push) Successful in 13m53s
deploy / deploy (push) Successful in 20s
fix: page through all chapters when the API omits total
listAllChapters used `page.total ?? all.length` as the loop bound. The
/chapters endpoint always returns `total: null`, so a full 200-row first
page broke the loop immediately — the reader dead-ended past chapter 200.
Stop on a short page instead; only trust `total` when actually reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:24:49 +02:00

208 lines
7.9 KiB
TypeScript

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<typeof globalThis.fetch>;
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.<ext>', 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$/);
});
});