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>
208 lines
7.9 KiB
TypeScript
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$/);
|
|
});
|
|
});
|