Add the /search surface (Pages / Chapters / Mangas tabs) backed by per-user page tags and per-page collections: schema (migration 0023), backend endpoints for page tags/collections and tagged-page aggregations (with the OCR text-search param reserved at 501), plus the frontend API clients, library Page-tags tab, collection page sections, page context menu / AddTagsSheet, and reader long-press wiring. Includes the continuous-reader navigation fixes (?page=N handling, chapter-reset timing, back-button pops history) and tag-normalization hardening accumulated on the branch. Bump version 0.60.2 -> 0.62.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
102 lines
3.3 KiB
TypeScript
102 lines
3.3 KiB
TypeScript
import {
|
|
describe,
|
|
it,
|
|
expect,
|
|
vi,
|
|
beforeEach,
|
|
afterEach,
|
|
type MockInstance
|
|
} from 'vitest';
|
|
import {
|
|
addPageToCollection,
|
|
removePageFromCollection,
|
|
listCollectionPages,
|
|
getMyCollectionsContainingPage
|
|
} from './page_collections';
|
|
|
|
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 pageItemFixture(extra: Record<string, unknown> = {}) {
|
|
return {
|
|
page_id: 'p1',
|
|
chapter_id: 'ch1',
|
|
manga_id: 'm1',
|
|
page_number: 1,
|
|
chapter_number: 1,
|
|
chapter_title: null,
|
|
manga_title: 'Berserk',
|
|
storage_key: 'mangas/m1/chapters/ch1/pages/0001.png',
|
|
added_at: '2026-01-01T00:00:00Z',
|
|
...extra
|
|
};
|
|
}
|
|
|
|
describe('page_collections api client', () => {
|
|
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('listCollectionPages hits the breadcrumb endpoint', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok({
|
|
items: [pageItemFixture()],
|
|
page: { limit: 50, offset: 0, total: 1 }
|
|
})
|
|
);
|
|
const r = await listCollectionPages('c1');
|
|
expect(r.items[0].manga_title).toBe('Berserk');
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/collections\/c1\/pages$/);
|
|
});
|
|
|
|
it('listCollectionPages forwards pagination params', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok({ items: [], page: { limit: 10, offset: 20, total: 0 } })
|
|
);
|
|
await listCollectionPages('c1', { limit: 10, offset: 20 });
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toContain('limit=10');
|
|
expect(url).toContain('offset=20');
|
|
});
|
|
|
|
it('addPageToCollection POSTs { page_id }', async () => {
|
|
fetchSpy.mockResolvedValueOnce(ok({}, 201));
|
|
await addPageToCollection('c1', 'p1');
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('POST');
|
|
expect(JSON.parse(init.body as string)).toEqual({ page_id: 'p1' });
|
|
});
|
|
|
|
it('removePageFromCollection DELETEs with both ids encoded', async () => {
|
|
fetchSpy.mockResolvedValueOnce(noContent());
|
|
await removePageFromCollection('c with space', 'p1');
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
// Path includes encoded "c with space" + page id.
|
|
expect(url).toContain('/v1/collections/c%20with%20space/pages/p1');
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('DELETE');
|
|
});
|
|
|
|
it('getMyCollectionsContainingPage unwraps `collection_ids`', async () => {
|
|
fetchSpy.mockResolvedValueOnce(ok({ collection_ids: ['c1', 'c2'] }));
|
|
const ids = await getMyCollectionsContainingPage('p1');
|
|
expect(ids).toEqual(['c1', 'c2']);
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/pages\/p1\/my-collections$/);
|
|
});
|
|
});
|