The chapter list on the manga detail page, the reader's chapter-select
dropdown, the continuous-mode chapter bar, the browser tab title, and
the profile upload-history entries all prepended "Chapter {number}:"
in front of the crawled site title. Source titles already include
"Ch.N" themselves and the manga page renders chapters inside an <ol>,
so the prefix duplicated information the user could already see.
A small chapterLabel(c) helper in $lib/api/chapters returns the site
title as-is, falling back to "Chapter {number}" only when the
crawler captured an empty title (link/option stays non-empty). The
five render sites now call it. The previous-/next-chapter nav
buttons still read "Previous chapter (Ch. N)" / "Next chapter (Ch. N)"
since those are wayfinding labels, not title display.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
166 lines
5.7 KiB
TypeScript
166 lines
5.7 KiB
TypeScript
import {
|
|
describe,
|
|
it,
|
|
expect,
|
|
vi,
|
|
beforeEach,
|
|
afterEach,
|
|
type MockInstance
|
|
} from 'vitest';
|
|
import {
|
|
listChapters,
|
|
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('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$/);
|
|
});
|
|
});
|