feat: manga metadata with status, authors, genres, tags, and search filters (0.15.0)

Adds first-class manga metadata across the stack:

- **Status** (ongoing / completed), **alternative titles**, normalized
  **multi-author** support, **curated genres** (13 seeded), and
  **free-form user tags** (case-insensitive, globally shared). Each is
  modelled as its own table joined to mangas; `mangas.author` is
  backfilled into `authors` + `manga_authors` and dropped.
- New endpoints: `PATCH /v1/mangas/:id` (three-state `description`),
  `POST/DELETE /v1/mangas/:id/tags[/:tag_id]`, `GET /v1/genres`,
  `GET /v1/tags?search=`.
- `GET /v1/mangas` now returns `MangaCard` (with authors + genres
  batched in) and supports `?status=`, `?author_id=`, `?genre_id=`,
  `?tag_id=` filters — AND across facets, with empty-array no-op
  semantics for the unnest primitive.
- `GET /v1/mangas/:id` returns the enriched `MangaDetail` with tags.
- Frontend: reusable `Chip` component; manga detail page renders
  authors as chips linking to `/authors/:id` (Phase 2), a status
  badge, alt titles, genres, and tags with inline add/remove (only
  the attacher sees remove); upload form supports multi-author /
  multi-genre / alt titles / status; search page gets a collapsible
  URL-synced filter panel with keyboard-navigable tag autocomplete.
- 126 backend tests (incl. AND-across-facets primitive, case-insens
  author/tag de-dup, transactional create rollback, PATCH semantics
  for missing / null / set on description); 72 frontend tests +
  svelte-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-17 14:32:03 +02:00
parent 60cc7712fa
commit 59d380b6d7
34 changed files with 3614 additions and 174 deletions

View File

@@ -1,13 +1,24 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import { listMangas, createManga, getManga } from './mangas';
import {
listMangas,
createManga,
getManga,
updateManga,
attachTag,
detachTag
} from './mangas';
function ok(body: unknown): Response {
function ok(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status: 200,
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,
@@ -19,6 +30,30 @@ 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>;
@@ -37,54 +72,65 @@ describe('mangas api client', () => {
expect(url).toMatch(/\/v1\/mangas$/);
});
it('listMangas returns the paged envelope', async () => {
it('listMangas returns the paged envelope with cards', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
items: [
{
id: 'b1',
title: 'Berserk',
author: 'Miura',
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
}
],
page: { limit: 50, offset: 0, total: null }
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.page).toEqual({ limit: 50, offset: 0, total: null });
expect(result.items[0].authors[0].name).toBe('Kentaro Miura');
expect(result.page).toEqual({ limit: 50, offset: 0, total: 1 });
});
it('listMangas encodes search, limit, offset, sort', async () => {
it('listMangas encodes search, status, ids (csv), limit, offset, sort', async () => {
fetchSpy.mockResolvedValueOnce(ok(emptyPage()));
await listMangas({ search: 'one piece', limit: 10, offset: 20, sort: 'title' });
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('createManga POSTs multipart with metadata to /v1/mangas', async () => {
it('getManga returns the enriched detail shape', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
id: 'abc',
title: 'Berserk',
author: 'Miura',
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
})
ok(detailFixture({ tags: [{ id: 't1', name: 'Seinen', added_by: 'u1' }] }))
);
const m = await createManga({ title: 'Berserk', author: 'Miura' });
expect(m.title).toBe('Berserk');
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;
@@ -94,13 +140,17 @@ describe('mangas api client', () => {
const metadata = form.get('metadata') as Blob;
expect(metadata).toBeInstanceOf(Blob);
expect(metadata.type).toBe('application/json');
// jsdom doesn't implement Blob.text(); read the bytes via FileReader.
const text = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsText(metadata);
});
expect(text).toBe(JSON.stringify({ title: 'Berserk', author: 'Miura' }));
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.
@@ -108,17 +158,7 @@ describe('mangas api client', () => {
});
it('createManga attaches the cover Blob when supplied', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
id: 'abc',
title: 'Berserk',
author: null,
description: null,
cover_image_path: 'mangas/abc/cover.png',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
})
);
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;
@@ -127,6 +167,45 @@ describe('mangas api client', () => {
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('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({