feat: edit existing manga metadata (0.31.0)

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>
This commit is contained in:
MechaCat02
2026-05-27 20:26:23 +02:00
parent 9ff49166a5
commit fa0a7da311
14 changed files with 1277 additions and 19 deletions

View File

@@ -4,6 +4,8 @@ import {
createManga,
getManga,
updateManga,
updateMangaCover,
deleteMangaCover,
attachTag,
detachTag
} from './mangas';
@@ -184,6 +186,49 @@ describe('mangas api client', () => {
});
});
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)