Adds PUT /mangas/:id/cover (multipart) and DELETE /mangas/:id/cover so covers can be replaced or cleared after creation, and wires a dedicated /manga/[id]/edit SvelteKit route that combines the existing PATCH with the new cover endpoints. Cover PUT cleans up the old blob when the extension changes, swallowing StorageError::NotFound so a manually-gone file doesn't surface as a 404 to the client. Edit link on the manga detail page is gated on session.user, matching the auth posture of the underlying handlers. Also pins the local-dev port story via loadEnv() in vite.config.ts so VITE_PORT / BACKEND_URL from a (gitignored) .env keep the dev URL stable across runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
264 lines
9.9 KiB
TypeScript
264 lines
9.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
|
import {
|
|
listMangas,
|
|
createManga,
|
|
getManga,
|
|
updateManga,
|
|
updateMangaCover,
|
|
deleteMangaCover,
|
|
attachTag,
|
|
detachTag
|
|
} from './mangas';
|
|
|
|
function ok(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
|
|
function noContent(): Response {
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
|
|
function envelope(status: number, code: string, message: string): Response {
|
|
return new Response(JSON.stringify({ error: { code, message } }), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
|
|
function emptyPage() {
|
|
return { items: [], page: { limit: 50, offset: 0, total: null } };
|
|
}
|
|
|
|
function cardFixture(extra: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 'b1',
|
|
title: 'Berserk',
|
|
status: 'ongoing',
|
|
alt_titles: [],
|
|
description: null,
|
|
cover_image_path: null,
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
authors: [{ id: 'a1', name: 'Kentaro Miura' }],
|
|
genres: [],
|
|
...extra
|
|
};
|
|
}
|
|
|
|
function detailFixture(extra: Record<string, unknown> = {}) {
|
|
return {
|
|
...cardFixture(),
|
|
tags: [],
|
|
...extra
|
|
};
|
|
}
|
|
|
|
describe('mangas api client', () => {
|
|
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('listMangas hits /v1/mangas with no params by default', async () => {
|
|
fetchSpy.mockResolvedValueOnce(ok(emptyPage()));
|
|
await listMangas();
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/mangas$/);
|
|
});
|
|
|
|
it('listMangas returns the paged envelope with cards', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok({
|
|
items: [cardFixture()],
|
|
page: { limit: 50, offset: 0, total: 1 }
|
|
})
|
|
);
|
|
const result = await listMangas();
|
|
expect(result.items).toHaveLength(1);
|
|
expect(result.items[0].title).toBe('Berserk');
|
|
expect(result.items[0].authors[0].name).toBe('Kentaro Miura');
|
|
expect(result.page).toEqual({ limit: 50, offset: 0, total: 1 });
|
|
});
|
|
|
|
it('listMangas encodes search, status, ids (csv), limit, offset, sort', async () => {
|
|
fetchSpy.mockResolvedValueOnce(ok(emptyPage()));
|
|
await listMangas({
|
|
search: 'one piece',
|
|
status: 'completed',
|
|
authorIds: ['a1', 'a2'],
|
|
genreIds: ['g1'],
|
|
tagIds: ['t1', 't2'],
|
|
limit: 10,
|
|
offset: 20,
|
|
sort: 'title'
|
|
});
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/mangas\?/);
|
|
expect(url).toContain('search=one+piece');
|
|
expect(url).toContain('status=completed');
|
|
// Multi-value facets land as a single comma-separated param so the
|
|
// backend can apply pure AND semantics across the list.
|
|
expect(url).toContain('author_id=a1%2Ca2');
|
|
expect(url).toContain('genre_id=g1');
|
|
expect(url).toContain('tag_id=t1%2Ct2');
|
|
expect(url).toContain('limit=10');
|
|
expect(url).toContain('offset=20');
|
|
expect(url).toContain('sort=title');
|
|
});
|
|
|
|
it('getManga returns the enriched detail shape', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok(detailFixture({ tags: [{ id: 't1', name: 'Seinen', added_by: 'u1' }] }))
|
|
);
|
|
const m = await getManga('b1');
|
|
expect(m.tags).toHaveLength(1);
|
|
expect(m.tags[0].name).toBe('Seinen');
|
|
expect(m.authors[0].name).toBe('Kentaro Miura');
|
|
});
|
|
|
|
it('createManga POSTs multipart with the new metadata shape', async () => {
|
|
fetchSpy.mockResolvedValueOnce(ok(detailFixture()));
|
|
await createManga({
|
|
title: 'Berserk',
|
|
authors: ['Kentaro Miura'],
|
|
status: 'completed',
|
|
alt_titles: ['ベルセルク'],
|
|
genre_ids: ['g1']
|
|
});
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/mangas$/);
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('POST');
|
|
expect(init.body).toBeInstanceOf(FormData);
|
|
const form = init.body as FormData;
|
|
const metadata = form.get('metadata') as Blob;
|
|
expect(metadata).toBeInstanceOf(Blob);
|
|
expect(metadata.type).toBe('application/json');
|
|
const text = await new Promise<string>((resolve) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result as string);
|
|
reader.readAsText(metadata);
|
|
});
|
|
const parsed = JSON.parse(text);
|
|
expect(parsed.title).toBe('Berserk');
|
|
expect(parsed.authors).toEqual(['Kentaro Miura']);
|
|
expect(parsed.status).toBe('completed');
|
|
expect(parsed.alt_titles).toEqual(['ベルセルク']);
|
|
expect(parsed.genre_ids).toEqual(['g1']);
|
|
expect(form.get('cover')).toBeNull();
|
|
// The browser sets Content-Type with boundary automatically when body
|
|
// is a FormData — we must NOT set it ourselves.
|
|
expect(init.headers).toBeUndefined();
|
|
});
|
|
|
|
it('createManga attaches the cover Blob when supplied', async () => {
|
|
fetchSpy.mockResolvedValueOnce(ok(detailFixture()));
|
|
const cover = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' });
|
|
await createManga({ title: 'Berserk' }, cover);
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
const form = init.body as FormData;
|
|
const got = form.get('cover');
|
|
expect(got).toBeInstanceOf(Blob);
|
|
});
|
|
|
|
it('updateManga PATCHes with the provided patch body', async () => {
|
|
fetchSpy.mockResolvedValueOnce(ok(detailFixture({ status: 'completed' })));
|
|
const updated = await updateManga('b1', {
|
|
status: 'completed',
|
|
authors: ['Kentaro Miura', 'Studio Gaga']
|
|
});
|
|
expect(updated.status).toBe('completed');
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/mangas\/b1$/);
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('PATCH');
|
|
expect(JSON.parse(init.body as string)).toEqual({
|
|
status: 'completed',
|
|
authors: ['Kentaro Miura', 'Studio Gaga']
|
|
});
|
|
});
|
|
|
|
it('updateMangaCover PUTs multipart with the cover blob', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok(detailFixture({ cover_image_path: 'mangas/b1/cover.png' }))
|
|
);
|
|
const cover = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' });
|
|
const updated = await updateMangaCover('b1', cover);
|
|
expect(updated.cover_image_path).toBe('mangas/b1/cover.png');
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/mangas\/b1\/cover$/);
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('PUT');
|
|
expect(init.body).toBeInstanceOf(FormData);
|
|
const form = init.body as FormData;
|
|
expect(form.get('cover')).toBeInstanceOf(Blob);
|
|
// Boundary is filled in by the browser when body is FormData.
|
|
expect(init.headers).toBeUndefined();
|
|
});
|
|
|
|
it('updateMangaCover throws ApiError on payload_too_large', async () => {
|
|
fetchSpy.mockResolvedValue(
|
|
envelope(413, 'payload_too_large', 'cover exceeds size cap')
|
|
);
|
|
const cover = new Blob([new Uint8Array(1)]);
|
|
await expect(updateMangaCover('b1', cover)).rejects.toMatchObject({
|
|
name: 'ApiError',
|
|
status: 413,
|
|
code: 'payload_too_large'
|
|
});
|
|
});
|
|
|
|
it('deleteMangaCover DELETEs and returns the refreshed detail with null path', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok(detailFixture({ cover_image_path: null }))
|
|
);
|
|
const updated = await deleteMangaCover('b1');
|
|
expect(updated.cover_image_path).toBeNull();
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/mangas\/b1\/cover$/);
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('DELETE');
|
|
expect(init.body).toBeUndefined();
|
|
});
|
|
|
|
it('attachTag POSTs the name and returns the TagRef', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok({ id: 't9', name: 'Dark Fantasy', added_by: 'u1' }, 201)
|
|
);
|
|
const tag = await attachTag('b1', 'Dark Fantasy');
|
|
expect(tag.name).toBe('Dark Fantasy');
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/mangas\/b1\/tags$/);
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('POST');
|
|
expect(JSON.parse(init.body as string)).toEqual({ name: 'Dark Fantasy' });
|
|
});
|
|
|
|
it('detachTag DELETEs the (manga, tag) pair', async () => {
|
|
fetchSpy.mockResolvedValueOnce(noContent());
|
|
await detachTag('b1', 't9');
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/mangas\/b1\/tags\/t9$/);
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('DELETE');
|
|
});
|
|
|
|
it('getManga throws ApiError carrying the envelope code on non-2xx', async () => {
|
|
fetchSpy.mockResolvedValue(envelope(404, 'not_found', 'manga not found'));
|
|
await expect(getManga('missing')).rejects.toMatchObject({
|
|
name: 'ApiError',
|
|
status: 404,
|
|
code: 'not_found',
|
|
message: 'manga not found'
|
|
});
|
|
});
|
|
});
|