import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { listAdminUsers, deleteAdminUser, setUserAdmin, createAdminUser, listAdminMangas, listAdminChapters, getSystemStats, getOverviewStats, getStorageStats, backfillStorage, resyncManga, resyncChapter, getCrawlerStatus, crawlerStatusStreamUrl, runCrawlerPass, reconcileCrawler, restartCrawlerBrowser, updateCrawlerSession, clearCrawlerSessionExpired, listDeadJobs, requeueDeadJobs, listActiveJobs, listMissingCovers, reenqueueAnalysis, analyzePage, getAnalysisMangaCoverage, getAnalysisChapterCoverage, getAnalysisChapterPages, getAnalysisPageDetail, analysisStatusStreamUrl, listCrawlerJobHistory, listAnalysisHistory, getCrawlerMetrics, listCrawlerOps, getAnalysisMetrics, listAuditLog, getHealth, updateHealthThresholds, getCrawlerMetricsSeries, getAnalysisMetricsSeries } from './admin'; 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' } }); } const userFixture = { id: 'u-1', username: 'alice', created_at: '2026-01-01T00:00:00Z', is_admin: false }; const mangaFixture = { id: 'm-1', title: 'Test', status: 'ongoing', cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', sync_state: 'synced' as const, chapter_count: 3, latest_seen_at: '2026-01-02T00:00:00Z' }; const systemFixture = { disk: { total_bytes: 1_000_000, used_bytes: 500_000, free_bytes: 500_000, percent_used: 50.0 }, memory: { total_bytes: 8_000_000, used_bytes: 4_000_000, percent_used: 50.0 }, cpu: { percent_used: 12.3, load_avg: { one: 0.81, five: 0.66, fifteen: 0.59 }, per_core: [10.0, 14.5, 8.2, 20.1] }, temperatures: [ { label: 'CPU Package', celsius: 52.0, max_celsius: 71.0, critical_celsius: 100.0 } ], alerts: [] }; describe('admin api client', () => { let fetchSpy: MockInstance; beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { vi.restoreAllMocks(); }); // ---- users ---- it('listAdminUsers GETs /v1/admin/users and parses the paged envelope', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [userFixture], page: { limit: 50, offset: 0, total: 1 } }) ); const page = await listAdminUsers({ limit: 50 }); expect(page.items).toHaveLength(1); expect(page.items[0]).toEqual(userFixture); expect(page.page.total).toBe(1); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/users\?limit=50$/); }); it('listAdminUsers forwards search + offset query params', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [], page: { limit: 50, offset: 10, total: 0 } }) ); await listAdminUsers({ search: 'al', offset: 10 }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain('search=al'); expect(url).toContain('offset=10'); }); it('listAdminUsers surfaces 403 forbidden via ApiError.code', async () => { fetchSpy.mockResolvedValueOnce(envelope(403, 'forbidden', 'forbidden')); await expect(listAdminUsers()).rejects.toMatchObject({ status: 403, code: 'forbidden' }); }); it('deleteAdminUser DELETEs to /v1/admin/users/{id} and handles 204', async () => { fetchSpy.mockResolvedValueOnce(noContent()); await expect(deleteAdminUser('u-1')).resolves.toBeUndefined(); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/users\/u-1$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('DELETE'); }); it('deleteAdminUser surfaces 409 conflict (self-delete / last-admin)', async () => { fetchSpy.mockResolvedValueOnce( envelope(409, 'conflict', 'cannot delete yourself; ask another admin') ); await expect(deleteAdminUser('u-1')).rejects.toMatchObject({ status: 409, code: 'conflict' }); }); it('createAdminUser POSTs to /v1/admin/users with body and returns the created user', async () => { const created = { ...userFixture, username: 'invited01' }; fetchSpy.mockResolvedValueOnce(ok(created, 201)); const got = await createAdminUser({ username: 'invited01', password: 'freshpass1234' }); expect(got).toEqual(created); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/users$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); expect(JSON.parse(init.body as string)).toEqual({ username: 'invited01', password: 'freshpass1234' }); }); it('createAdminUser forwards is_admin when provided', async () => { const created = { ...userFixture, username: 'coadmin', is_admin: true }; fetchSpy.mockResolvedValueOnce(ok(created, 201)); await createAdminUser({ username: 'coadmin', password: 'freshpass1234', is_admin: true }); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(JSON.parse(init.body as string)).toEqual({ username: 'coadmin', password: 'freshpass1234', is_admin: true }); }); it('createAdminUser surfaces 409 conflict on duplicate username', async () => { fetchSpy.mockResolvedValueOnce( envelope(409, 'conflict', 'username is already taken') ); await expect( createAdminUser({ username: 'taken', password: 'freshpass1234' }) ).rejects.toMatchObject({ status: 409, code: 'conflict' }); }); it('setUserAdmin PATCHes is_admin and returns the updated user', async () => { const updated = { ...userFixture, is_admin: true }; fetchSpy.mockResolvedValueOnce(ok(updated)); const got = await setUserAdmin('u-1', true); expect(got).toEqual(updated); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('PATCH'); expect(JSON.parse(init.body as string)).toEqual({ is_admin: true }); }); // ---- mangas + chapters ---- it('listAdminMangas GETs /v1/admin/mangas and forwards sync_state filter', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [mangaFixture], page: { limit: 100, offset: 0, total: 1 } }) ); const page = await listAdminMangas({ syncState: 'in_progress', limit: 100 }); expect(page.items[0].sync_state).toBe('synced'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain('sync_state=in_progress'); expect(url).toContain('limit=100'); }); it('listAdminChapters GETs the nested chapter route and parses the paged envelope', async () => { const chapter = { id: 'c-1', manga_id: 'm-1', number: 1, title: null, page_count: 12, created_at: '2026-01-01T00:00:00Z', sync_state: 'synced' as const, latest_seen_at: null }; fetchSpy.mockResolvedValueOnce( ok({ items: [chapter], page: { limit: 200, offset: 0, total: 1 } }) ); const resp = await listAdminChapters('m-1'); expect(resp.items).toEqual([chapter]); expect(resp.page.total).toBe(1); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/chapters$/); }); it('listAdminChapters forwards limit + offset query params', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [], page: { limit: 50, offset: 100, total: 0 } }) ); await listAdminChapters('m-1', { limit: 50, offset: 100 }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain('limit=50'); expect(url).toContain('offset=100'); }); // ---- system ---- it('getSystemStats GETs /v1/admin/system and parses the four-key envelope', async () => { fetchSpy.mockResolvedValueOnce(ok(systemFixture)); const s = await getSystemStats(); expect(s.disk?.percent_used).toBe(50); expect(s.memory.percent_used).toBe(50); expect(s.cpu.percent_used).toBe(12.3); expect(s.cpu.load_avg.one).toBe(0.81); expect(s.cpu.per_core).toHaveLength(4); expect(s.temperatures[0].label).toBe('CPU Package'); expect(s.temperatures[0].critical_celsius).toBe(100); expect(s.alerts).toEqual([]); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/system$/); }); it('getSystemStats keeps disk null when backend reports a non-local store', async () => { fetchSpy.mockResolvedValueOnce(ok({ ...systemFixture, disk: null })); const s = await getSystemStats(); expect(s.disk).toBeNull(); }); it('getSystemStats tolerates an empty temperatures array (sensors unavailable)', async () => { fetchSpy.mockResolvedValueOnce(ok({ ...systemFixture, temperatures: [] })); const s = await getSystemStats(); expect(s.temperatures).toEqual([]); }); it('getOverviewStats GETs /v1/admin/overview and parses the nested envelope', async () => { const fixture = { users: { total: 7, admins: 2, newest_username: 'kenji', newest_created_at: '2026-06-15T00:00:00Z' }, mangas: { total: 12, synced: 10, in_progress: 1, dropped: 1, total_chapters: 340, total_pages: 9001, newest_title: 'Sakamoto Days', newest_seen_at: '2026-06-16T00:00:00Z' }, analysis: { analyzed_pages: 5760, total_pages: 9001 } }; fetchSpy.mockResolvedValueOnce(ok(fixture)); const s = await getOverviewStats(); expect(s.users.total).toBe(7); expect(s.users.admins).toBe(2); expect(s.mangas.synced).toBe(10); expect(s.mangas.total_pages).toBe(9001); expect(s.analysis.analyzed_pages).toBe(5760); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/overview$/); }); // ---- force resync ---- it('resyncManga POSTs to /v1/admin/mangas/{id}/resync and returns the envelope', async () => { const resp = { manga: { id: 'm-1', title: 'T', status: 'ongoing', alt_titles: [], description: null, cover_image_path: 'mangas/m-1/cover.jpg', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-02T00:00:00Z', authors: [], genres: [], tags: [] }, metadata_status: 'updated', cover_fetched: true }; fetchSpy.mockResolvedValueOnce(ok(resp)); const got = await resyncManga('m-1'); expect(got.metadata_status).toBe('updated'); expect(got.cover_fetched).toBe(true); expect(got.manga.id).toBe('m-1'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/resync$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); }); it('resyncManga surfaces 503 service_unavailable when the daemon is off', async () => { fetchSpy.mockResolvedValueOnce( envelope(503, 'service_unavailable', 'crawler daemon is disabled') ); await expect(resyncManga('m-1')).rejects.toMatchObject({ status: 503, code: 'service_unavailable' }); }); it('resyncChapter POSTs to /v1/admin/chapters/{id}/resync and returns the envelope', async () => { const resp = { chapter: { id: 'c-1', manga_id: 'm-1', number: 1, title: 'Foo', page_count: 7, created_at: '2026-01-01T00:00:00Z' }, outcome: 'fetched', pages: 7 }; fetchSpy.mockResolvedValueOnce(ok(resp)); const got = await resyncChapter('c-1'); expect(got.outcome).toBe('fetched'); expect(got.pages).toBe(7); expect(got.chapter.page_count).toBe(7); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/chapters\/c-1\/resync$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); }); it('resyncChapter handles the "skipped" outcome envelope', async () => { const resp = { chapter: { id: 'c-1', manga_id: 'm-1', number: 1, title: null, page_count: 7, created_at: '2026-01-01T00:00:00Z' }, outcome: 'skipped', pages: null }; fetchSpy.mockResolvedValueOnce(ok(resp)); const got = await resyncChapter('c-1'); expect(got.outcome).toBe('skipped'); expect(got.pages).toBeNull(); }); }); describe('admin crawler api client', () => { let fetchSpy: MockInstance; beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { vi.restoreAllMocks(); }); const statusFixture = { daemon: 'running', phase: { state: 'fetching_metadata', index: 3, total: 10, title: 'One Piece' }, worker_count: 2, active_chapters: [ { manga_id: 'm-1', manga_title: 'Bleach', chapter_id: 'c-1', chapter_number: 12, pages_done: 4, pages_total: 20 } ], current_cover: { manga_id: 'm-2', manga_title: 'Naruto' }, covers_queued: 7, last_pass: { at: null, discovered: 0, upserted: 0, covers_fetched: 0, mangas_failed: 0 }, session: { expired: false, configured: true }, browser: 'healthy', queue: { pending: 2, running: 1, dead: 4 } }; it('crawlerStatusStreamUrl points at the SSE endpoint under the API base', () => { expect(crawlerStatusStreamUrl()).toMatch(/\/v1\/admin\/crawler\/stream$/); }); it('getCrawlerStatus GETs /v1/admin/crawler with live chapter/cover fields', async () => { fetchSpy.mockResolvedValueOnce(ok(statusFixture)); const s = await getCrawlerStatus(); expect(s.queue.dead).toBe(4); expect(s.phase?.state).toBe('fetching_metadata'); expect(s.active_chapters[0].pages_done).toBe(4); expect(s.active_chapters[0].pages_total).toBe(20); expect(s.current_cover?.manga_title).toBe('Naruto'); expect(s.covers_queued).toBe(7); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/crawler$/); }); it('listActiveJobs GETs /v1/admin/crawler/active-jobs with search', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [], page: { limit: 20, offset: 0, total: 0 } }) ); await listActiveJobs({ search: 'bleach' }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/crawler\/active-jobs\?/); expect(url).toContain('search=bleach'); }); it('listMissingCovers GETs /v1/admin/crawler/covers', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [{ manga_id: 'm-1', manga_title: 'X' }], page: { limit: 20, offset: 0, total: 1 } }) ); const r = await listMissingCovers(); expect(r.items[0].manga_title).toBe('X'); expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/covers$/); }); it('listAuditLog GETs /v1/admin/audit with the paged envelope', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [ { id: 'a-1', actor_user_id: 'u-1', actor_username: 'alice', action: 'crawler_run', target_kind: 'crawler', target_id: null, payload: {}, at: '2026-06-01T00:00:00Z' } ], page: { limit: 25, offset: 0, total: 1 } }) ); const r = await listAuditLog({ limit: 25 }); expect(r.items[0].action).toBe('crawler_run'); expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/audit\?limit=25$/); }); it('listAuditLog forwards action / target_kind / days filters', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [], page: { limit: 25, offset: 0, total: 0 } }) ); await listAuditLog({ action: 'manga_resync', targetKind: 'manga', days: 7 }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain('action=manga_resync'); expect(url).toContain('target_kind=manga'); expect(url).toContain('days=7'); }); const healthFixture = { status: 'ok' as const, checks: [ { id: 'memory', label: 'Memory usage', status: 'ok' as const, value: '48%', threshold: '90%', hint: null, action_href: null } ], thresholds: { disk_pct: 90, mem_pct: 90, dead_jobs_max: 100, crawl_fail_pct: 20, analysis_fail_pct: 20, cron_freshness_hours: 26, missing_covers_max: 500 } }; it('getCrawlerMetricsSeries GETs the crawler series with days', async () => { fetchSpy.mockResolvedValueOnce(ok({ buckets: [] })); await getCrawlerMetricsSeries(7); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/crawler\/metrics\/series\?days=7$/); }); it('getAnalysisMetricsSeries omits days=0 (all-time)', async () => { fetchSpy.mockResolvedValueOnce(ok({ buckets: [] })); await getAnalysisMetricsSeries(0); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/analysis\/metrics\/series$/); }); it('getHealth GETs /v1/admin/health', async () => { fetchSpy.mockResolvedValueOnce(ok(healthFixture)); const r = await getHealth(); expect(r.status).toBe('ok'); expect(r.checks[0].id).toBe('memory'); expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/health$/); }); it('updateHealthThresholds PUTs the thresholds body', async () => { fetchSpy.mockResolvedValueOnce(ok(healthFixture)); await updateHealthThresholds(healthFixture.thresholds); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/health\/thresholds$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('PUT'); expect(JSON.parse(init.body as string).dead_jobs_max).toBe(100); }); it('runCrawlerPass POSTs /v1/admin/crawler/run', async () => { fetchSpy.mockResolvedValueOnce(ok({ started: true })); const r = await runCrawlerPass(); expect(r.started).toBe(true); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/run$/); }); it('reconcileCrawler POSTs /v1/admin/crawler/reconcile', async () => { fetchSpy.mockResolvedValueOnce(ok({ started: true })); const r = await reconcileCrawler(); expect(r.started).toBe(true); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/reconcile$/); }); it('restartCrawlerBrowser POSTs the restart endpoint', async () => { fetchSpy.mockResolvedValueOnce(ok({ ok: true, error: null })); const r = await restartCrawlerBrowser(); expect(r.ok).toBe(true); expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/browser\/restart$/); }); it('updateCrawlerSession POSTs the phpsessid body', async () => { fetchSpy.mockResolvedValueOnce(ok({ valid: true, error: null })); const r = await updateCrawlerSession('abc123'); expect(r.valid).toBe(true); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); expect(JSON.parse(init.body as string)).toEqual({ phpsessid: 'abc123' }); }); it('clearCrawlerSessionExpired POSTs clear-expired', async () => { fetchSpy.mockResolvedValueOnce(ok({ cleared: true })); const r = await clearCrawlerSessionExpired(); expect(r.cleared).toBe(true); expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/session\/clear-expired$/); }); it('listDeadJobs forwards search + pagination', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [], page: { limit: 20, offset: 20, total: 0 } }) ); await listDeadJobs({ search: 'naruto', limit: 20, offset: 20 }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain('search=naruto'); expect(url).toContain('offset=20'); }); it('requeueDeadJobs POSTs the scope body', async () => { fetchSpy.mockResolvedValueOnce(ok({ requeued: 3 })); const r = await requeueDeadJobs({ scope: 'manga', manga_id: 'm-9' }); expect(r.requeued).toBe(3); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(JSON.parse(init.body as string)).toEqual({ scope: 'manga', manga_id: 'm-9' }); }); it('requeueDeadJobs serialises chapter scope verbatim', async () => { // F7 — the per-chapter requeue button in /admin/mangas sends this // exact shape; pin it so a future rename of `chapter_id` doesn't // silently break the inline button. fetchSpy.mockResolvedValueOnce(ok({ requeued: 1 })); const r = await requeueDeadJobs({ scope: 'chapter', chapter_id: 'c-7' }); expect(r.requeued).toBe(1); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(JSON.parse(init.body as string)).toEqual({ scope: 'chapter', chapter_id: 'c-7' }); }); it('requeueDeadJobs serialises job + all (with confirm) scopes', async () => { // Round out the variant coverage: { scope: 'job', job_id } and // { scope: 'all', confirm: true }. The audit (S1) requires the // confirm flag on the wire for scope=all; this pins it so a // future refactor can't drop it. fetchSpy.mockResolvedValueOnce(ok({ requeued: 1 })); await requeueDeadJobs({ scope: 'job', job_id: 'j-1' }); expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({ scope: 'job', job_id: 'j-1' }); fetchSpy.mockResolvedValueOnce(ok({ requeued: 99 })); await requeueDeadJobs({ scope: 'all', confirm: true }); expect(JSON.parse(fetchSpy.mock.calls[1][1]!.body as string)).toEqual({ scope: 'all', confirm: true }); }); it('surfaces a 503 as ApiError', async () => { fetchSpy.mockResolvedValueOnce(envelope(503, 'service_unavailable', 'disabled')); await expect(runCrawlerPass()).rejects.toMatchObject({ status: 503 }); }); it('reenqueueAnalysis defaults to whole-library, exclude-analyzed', async () => { fetchSpy.mockResolvedValueOnce(ok({ enqueued: 42 })); const r = await reenqueueAnalysis(); expect(r.enqueued).toBe(42); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/analysis\/reenqueue$/); expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({ only_unanalyzed: true }); }); it('reenqueueAnalysis scopes to a manga and can include analyzed', async () => { fetchSpy.mockResolvedValueOnce(ok({ enqueued: 7 })); await reenqueueAnalysis({ mangaId: 'm1', onlyUnanalyzed: false }); expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({ only_unanalyzed: false, manga_id: 'm1' }); }); it('reenqueueAnalysis scopes to a chapter', async () => { fetchSpy.mockResolvedValueOnce(ok({ enqueued: 3 })); await reenqueueAnalysis({ chapterId: 'c1' }); expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({ only_unanalyzed: true, chapter_id: 'c1' }); }); it('analyzePage posts to the force-analyze endpoint', async () => { fetchSpy.mockResolvedValueOnce(ok({ enqueued: true })); const r = await analyzePage('p1'); expect(r.enqueued).toBe(true); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/pages\/p1\/analyze$/); expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST'); }); it('getAnalysisMangaCoverage passes search + pagination', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [], page: { limit: 25, offset: 0, total: 0 } }) ); await getAnalysisMangaCoverage({ search: 'ber', limit: 25, offset: 50 }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain('/v1/admin/analysis/mangas?'); expect(url).toContain('search=ber'); expect(url).toContain('limit=25'); expect(url).toContain('offset=50'); }); it('getAnalysisMangaCoverage forwards the AbortSignal to fetch', async () => { // The dashboard's debounce-race fix (0.87.9) relies on this knob // landing on `init.signal`. Without it, a slow first request // can still overwrite a faster second one because `request()` // builds a fresh fetch with no cancellation hook. fetchSpy.mockResolvedValueOnce( ok({ items: [], page: { limit: 50, offset: 0, total: 0 } }) ); const ctrl = new AbortController(); await getAnalysisMangaCoverage( { limit: 50 }, { signal: ctrl.signal } ); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.signal).toBe(ctrl.signal); }); it('getAnalysisMangaCoverage propagates an abort rejection', async () => { // Mid-flight abort: caller flips the AbortController, the fetch // promise rejects, and the api client lets the rejection // surface to its caller (the debounce-race handler treats // AbortError as silent cancellation rather than an error toast). const ctrl = new AbortController(); fetchSpy.mockImplementationOnce( (_url, init) => new Promise((_resolve, reject) => { const signal = (init as RequestInit).signal; signal?.addEventListener( 'abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true } ); }) ); const inflight = getAnalysisMangaCoverage( { limit: 25 }, { signal: ctrl.signal } ); ctrl.abort(); await expect(inflight).rejects.toMatchObject({ name: 'AbortError' }); }); it('getAnalysisChapterCoverage unwraps items', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [{ chapter_id: 'c1', number: 1, title: null, total_pages: 2, analyzed_pages: 1 }] }) ); const items = await getAnalysisChapterCoverage('m1'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/analysis\/mangas\/m1\/chapters$/); expect(items).toHaveLength(1); }); it('getAnalysisChapterPages unwraps items', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [{ page_id: 'p1', page_number: 1, status: 'done' }] }) ); const items = await getAnalysisChapterPages('c1'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/analysis\/chapters\/c1\/pages$/); expect(items[0].status).toBe('done'); }); it('getAnalysisPageDetail hits the page endpoint', async () => { fetchSpy.mockResolvedValueOnce( ok({ page_id: 'p1', status: 'none', ocr: [], tags: [], content_warnings: [] }) ); const d = await getAnalysisPageDetail('p1'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/analysis\/pages\/p1$/); expect(d.status).toBe('none'); }); it('analysisStatusStreamUrl points at the SSE endpoint', () => { expect(analysisStatusStreamUrl()).toMatch( /\/v1\/admin\/analysis\/status\/stream$/ ); }); // ---- storage usage ---- it('getStorageStats GETs /v1/admin/storage and parses the envelope', async () => { const fixture = { total_bytes: 700, covers_bytes: 100, chapters_bytes: 600, disk_total_bytes: 1_000_000, ratio_of_disk: 0.0007, avg_image_bytes: 233.3, avg_cover_bytes: 100, avg_chapter_page_bytes: 300, top_mangas: [{ id: 'm-1', title: 'Big', total_bytes: 650 }], top_chapters: [ { id: 'c-1', manga_id: 'm-1', number: 1, title: null, total_bytes: 600 } ], unmeasured_pages: 4, unmeasured_covers: 1 }; fetchSpy.mockResolvedValueOnce(ok(fixture)); const s = await getStorageStats(); expect(s.total_bytes).toBe(700); expect(s.top_mangas[0].title).toBe('Big'); expect(s.top_chapters[0].total_bytes).toBe(600); expect(s.unmeasured_pages).toBe(4); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/storage$/); }); it('getStorageStats keeps disk total + ratio null for a non-local store', async () => { fetchSpy.mockResolvedValueOnce( ok({ total_bytes: 0, covers_bytes: 0, chapters_bytes: 0, disk_total_bytes: null, ratio_of_disk: null, avg_image_bytes: null, avg_cover_bytes: null, avg_chapter_page_bytes: null, top_mangas: [], top_chapters: [], unmeasured_pages: 0, unmeasured_covers: 0 }) ); const s = await getStorageStats(); expect(s.disk_total_bytes).toBeNull(); expect(s.ratio_of_disk).toBeNull(); }); it('backfillStorage POSTs /v1/admin/storage/backfill and returns counts', async () => { fetchSpy.mockResolvedValueOnce( ok({ pages: 3, covers: 1, missing: 0, errored: 0, more_remaining: false }) ); const r = await backfillStorage(); expect(r).toEqual({ pages: 3, covers: 1, missing: 0, errored: 0, more_remaining: false }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/storage\/backfill$/); expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST'); }); // ---- job history ---- it('listCrawlerJobHistory forwards state/kind/search/limit/offset and parses items', async () => { const row = { id: 'j-1', state: 'done', kind: 'analyze_page', manga_id: 'm-1', manga_title: 'Berserk', chapter_id: 'c-1', chapter_number: 12, page_number: 7, source_key: null, attempts: 1, max_attempts: 5, last_error: null, updated_at: '2026-06-15T00:00:00Z' }; fetchSpy.mockResolvedValueOnce( ok({ items: [row], page: { limit: 50, offset: 0, total: 1 } }) ); const page = await listCrawlerJobHistory({ state: 'done', kind: 'analyze_page', search: 'Ber', offset: 10 }); expect(page.items[0]).toEqual(row); expect(page.page.total).toBe(1); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain('/v1/admin/crawler/history?'); expect(url).toContain('state=done'); expect(url).toContain('kind=analyze_page'); expect(url).toContain('search=Ber'); expect(url).toContain('offset=10'); }); it('listCrawlerJobHistory omits unset filters', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [], page: { limit: 50, offset: 0, total: 0 } }) ); await listCrawlerJobHistory(); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/crawler\/history$/); }); it('listAnalysisHistory forwards status/nsfw/search and parses items', async () => { const row = { page_id: 'p-1', page_number: 3, chapter_id: 'c-1', chapter_number: 700, manga_id: 'm-1', manga_title: 'Naruto', status: 'failed', is_nsfw: false, model: null, error: 'boom', analyzed_at: '2026-06-15T00:00:00Z' }; fetchSpy.mockResolvedValueOnce( ok({ items: [row], page: { limit: 25, offset: 0, total: 1 } }) ); const page = await listAnalysisHistory({ status: 'failed', nsfw: true, search: 'Nar' }); expect(page.items[0]).toEqual(row); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain('/v1/admin/analysis/history?'); expect(url).toContain('status=failed'); expect(url).toContain('nsfw=true'); expect(url).toContain('search=Nar'); }); it('listAnalysisHistory omits nsfw when false', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [], page: { limit: 25, offset: 0, total: 0 } }) ); await listAnalysisHistory({ status: 'done', nsfw: false }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain('status=done'); expect(url).not.toContain('nsfw'); }); // ---- operation metrics ---- it('getCrawlerMetrics omits days when 0 and parses the summary', async () => { fetchSpy.mockResolvedValueOnce( ok({ summary: [ { op: 'chapter', avg_ms: 7000, n: 2, ok: 1, failed: 1, avg_items: 15 } ] }) ); const r = await getCrawlerMetrics(0); expect(r.summary[0].avg_ms).toBe(7000); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/crawler\/metrics$/); }); it('getCrawlerMetrics forwards a positive days window', async () => { fetchSpy.mockResolvedValueOnce(ok({ summary: [] })); await getCrawlerMetrics(7); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain('days=7'); }); it('listCrawlerOps forwards op/outcome/days and parses the page', async () => { const row = { id: 'o-1', op: 'chapter', manga_id: 'm-1', manga_title: 'Berserk', chapter_id: 'c-1', chapter_number: 12, outcome: 'ok', duration_ms: 6100, items: 20, error: null, finished_at: '2026-06-15T00:00:00Z' }; fetchSpy.mockResolvedValueOnce( ok({ items: [row], page: { limit: 50, offset: 0, total: 1 } }) ); const page = await listCrawlerOps({ op: 'chapter', outcome: 'ok', days: 30 }); expect(page.items[0]).toEqual(row); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain('/v1/admin/crawler/metrics/ops?'); expect(url).toContain('op=chapter'); expect(url).toContain('outcome=ok'); expect(url).toContain('days=30'); }); it('getAnalysisMetrics parses aggregate + by-model', async () => { fetchSpy.mockResolvedValueOnce( ok({ n: 4, ok: 3, failed: 1, avg_ms: 2500, by_model: [{ model: 'qwen', avg_ms: 3000, n: 2 }] }) ); const m = await getAnalysisMetrics(7); expect(m.avg_ms).toBe(2500); expect(m.by_model[0].model).toBe('qwen'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain('/v1/admin/analysis/metrics?days=7'); }); });