feat(frontend): /admin dashboard with users/mangas/system views (0.41.0)
Adds the SvelteKit /admin route tree backed by the admin endpoints landed in PR 1-4. Pages: Overview (alerts + summary cards), Users (list / promote-demote / delete), Mangas (list with sync state + expandable per-chapter state), System (live disk/mem/cpu bars, refreshing every 5s). Security model: the backend's RequireAdmin extractor is the actual boundary. /admin/+layout.ts calls getSystemStats() at load and translates the response — 401 → redirect to /login, 403 → throw SvelteKit error(403) which renders the framework error page. The header's "Admin" link is hidden unless `session.user?.is_admin`, but that's UX only. Carries `is_admin: boolean` through to the frontend User TS type so the header check works and so admin tables can show role per row. Vitest covers lib/api/admin.ts (10 tests: list/delete/PATCH for users, sync-state filter for mangas, nested chapter route, system disk-nullable case). Playwright is intentionally deferred until the routes stabilise — admin UI is operator-only and changes shape often in v0.
This commit is contained in:
188
frontend/src/lib/api/admin.test.ts
Normal file
188
frontend/src/lib/api/admin.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance
|
||||
} from 'vitest';
|
||||
import {
|
||||
listAdminUsers,
|
||||
deleteAdminUser,
|
||||
setUserAdmin,
|
||||
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<typeof globalThis.fetch>;
|
||||
|
||||
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('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', 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([chapter]));
|
||||
const rows = await listAdminChapters('m-1');
|
||||
expect(rows).toEqual([chapter]);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/chapters$/);
|
||||
});
|
||||
|
||||
// ---- 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user