import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { listAdminUsers, deleteAdminUser, setUserAdmin, createAdminUser, listAdminMangas, listAdminChapters, getSystemStats } 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(); }); });