import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { listAdminUsers, deleteAdminUser, setUserAdmin, createAdminUser, listAdminMangas, listAdminChapters, getSystemStats, resyncManga, resyncChapter, getCrawlerStatus, crawlerStatusStreamUrl, runCrawlerPass, restartCrawlerBrowser, updateCrawlerSession, clearCrawlerSessionExpired, listDeadJobs, requeueDeadJobs, listActiveJobs, listMissingCovers } 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 }, 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.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(); }); // ---- 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('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('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('surfaces a 503 as ApiError', async () => { fetchSpy.mockResolvedValueOnce(envelope(503, 'service_unavailable', 'disabled')); await expect(runCrawlerPass()).rejects.toMatchObject({ status: 503 }); }); });