Real-world sources publish multiple chapters at the same number:
different scanlators ("Ch.52 from bloomingdale" + "Ch.52 from mina"),
translator notices and farewells, alt-translations. The (manga_id,
number) UNIQUE constraint from 0001 silently collapsed all of those
into a single row via the upsert path in repo::crawler. Migration 0013
drops the constraint; sync_manga_chapters now plain-INSERTs each
SourceChapterRef so every parsed chapter survives as its own row.
Identity moves from the (manga_id, number) tuple to the chapter UUID:
- `GET /api/v1/mangas/:manga_id/chapters/:chapter_id` (replaces :number)
- `GET /api/v1/mangas/:manga_id/chapters/:chapter_id/pages`
- `repo::chapter::find_by_id_in_manga` (replaces find_by_manga_and_number)
- Frontend reader route renamed to `/manga/[id]/chapter/[chapter_id]`
- Chapter links throughout (manga page list, continue-reading CTA,
reader prev/next, history rows, bookmark cards) use chapter.id
- API clients getChapter/getChapterPages take a chapter id string
read_progress + bookmarks already FK chapter_id; they only enrich with
chapter_number for display, which is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
153 lines
5.3 KiB
TypeScript
153 lines
5.3 KiB
TypeScript
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<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/);
|
|
}
|
|
});
|
|
|
|
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$/);
|
|
});
|
|
});
|