diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte
index 40f1c6d6..530a1e69 100644
--- a/web/src/routes/admin/+page.svelte
+++ b/web/src/routes/admin/+page.svelte
@@ -1,5 +1,34 @@
-
+
-
+
+
+
+
+
+ {#if requests.isPending}
+ Loading…
+ {:else if requestPreview.length === 0}
+ No pending requests.
+ {:else}
+
+ {#each requestPreview as r (r.id)}
+ {@const Icon = fallbackIcon(r.kind)}
+ -
+
+
+
{rowTitle(r)}
+
{rowSubtitle(r)}
+
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+ {#if quarantine.isPending}
+ Loading…
+ {:else if quarantinePreview.length === 0}
+ No quarantined tracks.
+ {:else}
+
+ {#each quarantinePreview as row (row.track_id)}
+ {@const fileKey = confirmKey(row.track_id, 'delete-file')}
+ {@const lidarrKey = confirmKey(row.track_id, 'delete-lidarr')}
+ {@const fileArmed = confirmingKey === fileKey}
+ {@const lidarrArmed = confirmingKey === lidarrKey}
+ -
+
+
+
{row.track_title}
+
+ {row.artist_name} · {row.album_title}
+ · {row.report_count} {row.report_count === 1 ? 'report' : 'reports'}
+ {#if row.reports[0]}
+ · {REASON_LABELS[row.reports[0].reason] ?? row.reports[0].reason}
+ {/if}
+
+
+
+
+
+
+
+
+
+ {/each}
+
+ {/if}
+
+
+{#if toast}
+
+ {toast}
+
+{/if}
diff --git a/web/src/routes/admin/admin.test.ts b/web/src/routes/admin/admin.test.ts
index e1221f9b..e49d8516 100644
--- a/web/src/routes/admin/admin.test.ts
+++ b/web/src/routes/admin/admin.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
-import { render, screen } from '@testing-library/svelte';
+import { render, screen, fireEvent, cleanup } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
// Mutable mock-user handle so individual tests can flip is_admin / null
@@ -18,10 +18,25 @@ vi.mock('$app/state', () => ({
vi.mock('$lib/api/admin', () => ({
createAdminRequestsQuery: vi.fn(),
- createLidarrConfigQuery: vi.fn()
+ createLidarrConfigQuery: vi.fn(),
+ createAdminQuarantineQuery: vi.fn(),
+ approveRequest: vi.fn().mockResolvedValue({}),
+ rejectRequest: vi.fn().mockResolvedValue({}),
+ resolveQuarantine: vi.fn().mockResolvedValue({}),
+ deleteQuarantineFile: vi.fn().mockResolvedValue({}),
+ deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({})
}));
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record
;
+ return {
+ ...actual,
+ useQueryClient: () => ({ invalidateQueries: vi.fn() })
+ };
+});
+
afterEach(() => {
+ cleanup();
vi.clearAllMocks();
userState.current = null;
});
@@ -31,8 +46,6 @@ describe('admin/+layout.ts load gate', () => {
userState.current = { id: 'u1', username: 'alice', is_admin: false };
const { load } = await import('./+layout');
await expect(async () =>
- // The SvelteKit `load` signature wants a LoadEvent; we don't read any of
- // it inside the function, so an empty cast is sufficient for this gate.
load({} as Parameters[0])
).rejects.toMatchObject({ status: 302, location: '/' });
});
@@ -48,55 +61,165 @@ describe('admin/+layout.ts load gate', () => {
test('admin user passes the gate', async () => {
userState.current = { id: 'u1', username: 'root', is_admin: true };
const { load } = await import('./+layout');
- // load() is synchronous in the happy path; it only throws on the gate.
expect(load({} as Parameters[0])).toEqual({});
});
});
+// Top-level imports — Svelte/Vite caches the module graph across tests,
+// and dynamic re-imports inside each test produced "stale-mock" symptoms
+// where the page picked up empty data despite `mockReturnValue` being set.
+// Importing at module load means the mocks attach to the same handles the
+// page binds against.
+import {
+ createAdminRequestsQuery,
+ createLidarrConfigQuery,
+ createAdminQuarantineQuery,
+ approveRequest,
+ rejectRequest,
+ resolveQuarantine,
+ deleteQuarantineFile,
+ deleteQuarantineViaLidarr
+} from '$lib/api/admin';
+import OverviewPage from './+page.svelte';
+
+function setupOverview(opts: {
+ requests?: unknown[];
+ quarantine?: unknown[];
+ lidarr?: { enabled: boolean };
+} = {}) {
+ (createAdminRequestsQuery as ReturnType).mockReturnValue(
+ mockQuery({ data: opts.requests ?? [] })
+ );
+ (createAdminQuarantineQuery as ReturnType).mockReturnValue(
+ mockQuery({ data: opts.quarantine ?? [] })
+ );
+ (createLidarrConfigQuery as ReturnType).mockReturnValue(
+ mockQuery({ data: opts.lidarr ?? { enabled: false } })
+ );
+}
+
describe('admin Overview page', () => {
- test('renders pending requests count and Lidarr status (connected)', async () => {
- const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin');
- (createAdminRequestsQuery as ReturnType).mockReturnValue(
- mockQuery({ data: [{ id: '1' }, { id: '2' }] })
- );
- (createLidarrConfigQuery as ReturnType).mockReturnValue(
- mockQuery({ data: { enabled: true } })
- );
- const { default: OverviewPage } = await import('./+page.svelte');
+ test('renders top stat cards (requests, quarantine, lidarr)', async () => {
+ setupOverview({
+ requests: [{ id: '1' }, { id: '2' }],
+ quarantine: [{ track_id: 't1', report_count: 2, reports: [] }],
+ lidarr: { enabled: true }
+ });
render(OverviewPage);
- expect(screen.getByText('Pending requests')).toBeInTheDocument();
- expect(screen.getByText('2')).toBeInTheDocument();
- expect(screen.getByText('Lidarr')).toBeInTheDocument();
+ // "Pending requests" appears as both stat-card label and section
+ // header — assert via the link role to disambiguate to the stat card.
+ const reqLink = screen.getByRole('link', { name: /pending requests/i });
+ expect(reqLink).toHaveTextContent('2');
+ const quarLink = screen.getByRole('link', { name: /quarantined tracks/i });
+ expect(quarLink).toHaveTextContent('1');
expect(screen.getByText(/connected/i)).toBeInTheDocument();
});
- test('shows zero pending and "Unset" when nothing is configured', async () => {
- const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin');
- (createAdminRequestsQuery as ReturnType).mockReturnValue(
- mockQuery({ data: [] })
- );
- (createLidarrConfigQuery as ReturnType).mockReturnValue(
- mockQuery({ data: { enabled: false } })
- );
- const { default: OverviewPage } = await import('./+page.svelte');
+ test('shows empty-state copy when both queues are empty', async () => {
+ setupOverview({
+ requests: [],
+ quarantine: [],
+ lidarr: { enabled: false }
+ });
render(OverviewPage);
- expect(screen.getByText('0')).toBeInTheDocument();
+ expect(screen.getByText(/no pending requests/i)).toBeInTheDocument();
+ expect(screen.getByText(/no quarantined tracks/i)).toBeInTheDocument();
expect(screen.getByText(/unset/i)).toBeInTheDocument();
});
- test('Overview cards link to their sub-pages', async () => {
- const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin');
- (createAdminRequestsQuery as ReturnType).mockReturnValue(
- mockQuery({ data: [] })
- );
- (createLidarrConfigQuery as ReturnType).mockReturnValue(
- mockQuery({ data: { enabled: false } })
- );
- const { default: OverviewPage } = await import('./+page.svelte');
+ test('top stat cards link to their sub-pages', async () => {
+ setupOverview();
render(OverviewPage);
- const reqLink = screen.getByRole('link', { name: /pending requests/i });
- expect(reqLink).toHaveAttribute('href', '/admin/requests');
- const lidarrLink = screen.getByRole('link', { name: /lidarr/i });
- expect(lidarrLink).toHaveAttribute('href', '/admin/integrations');
+ expect(screen.getByRole('link', { name: /pending requests/i }))
+ .toHaveAttribute('href', '/admin/requests');
+ expect(screen.getByRole('link', { name: /quarantined tracks/i }))
+ .toHaveAttribute('href', '/admin/quarantine');
+ expect(screen.getByRole('link', { name: /^lidarr/i }))
+ .toHaveAttribute('href', '/admin/integrations');
+ });
+
+ test('clicking Approve on a request row calls approveRequest', async () => {
+ setupOverview({
+ requests: [
+ { id: 'r1', kind: 'artist', artist_name: 'Boards of Canada', status: 'pending' }
+ ]
+ });
+ render(OverviewPage);
+ await fireEvent.click(screen.getByRole('button', { name: /approve boards of canada/i }));
+ expect(approveRequest).toHaveBeenCalledWith('r1');
+ });
+
+ test('clicking Reject on a request row calls rejectRequest', async () => {
+ setupOverview({
+ requests: [
+ { id: 'r1', kind: 'artist', artist_name: 'Boards of Canada', status: 'pending' }
+ ]
+ });
+ render(OverviewPage);
+ await fireEvent.click(screen.getByRole('button', { name: /reject boards of canada/i }));
+ expect(rejectRequest).toHaveBeenCalledWith('r1');
+ });
+
+ test('Resolve fires immediately on a quarantine row', async () => {
+ setupOverview({
+ quarantine: [
+ { track_id: 't1', track_title: 'Roygbiv', artist_name: 'BoC',
+ album_title: 'Geogaddi', report_count: 1, reports: [] }
+ ]
+ });
+ render(OverviewPage);
+ await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i }));
+ expect(resolveQuarantine).toHaveBeenCalledWith('t1');
+ });
+
+ test('Delete file requires two clicks (inline confirm)', async () => {
+ setupOverview({
+ quarantine: [
+ { track_id: 't1', track_title: 'Roygbiv', artist_name: 'BoC',
+ album_title: 'Geogaddi', report_count: 1, reports: [] }
+ ]
+ });
+ render(OverviewPage);
+ // First click: arms the confirm; no API call yet.
+ await fireEvent.click(screen.getByRole('button', { name: /^delete file roygbiv$/i }));
+ expect(deleteQuarantineFile).not.toHaveBeenCalled();
+ // Second click on the now-armed button fires the action.
+ await fireEvent.click(
+ screen.getByRole('button', { name: /confirm delete file roygbiv/i })
+ );
+ expect(deleteQuarantineFile).toHaveBeenCalledWith('t1');
+ });
+
+ test('Delete via Lidarr requires two clicks (inline confirm)', async () => {
+ setupOverview({
+ quarantine: [
+ { track_id: 't1', track_title: 'Roygbiv', artist_name: 'BoC',
+ album_title: 'Geogaddi', report_count: 1, reports: [] }
+ ]
+ });
+ render(OverviewPage);
+ await fireEvent.click(
+ screen.getByRole('button', { name: /^delete via lidarr roygbiv$/i })
+ );
+ expect(deleteQuarantineViaLidarr).not.toHaveBeenCalled();
+ await fireEvent.click(
+ screen.getByRole('button', { name: /confirm delete via lidarr roygbiv/i })
+ );
+ expect(deleteQuarantineViaLidarr).toHaveBeenCalledWith('t1');
+ });
+
+ test('"View all" link surfaces only when count exceeds the preview limit', async () => {
+ // 6 requests > limit of 5; quarantine empty.
+ const six = Array.from({ length: 6 }, (_, i) => ({
+ id: `r${i}`,
+ kind: 'artist' as const,
+ artist_name: `Artist ${i}`,
+ status: 'pending' as const
+ }));
+ setupOverview({ requests: six });
+ render(OverviewPage);
+ expect(screen.getByRole('link', { name: /view all 6/i })).toHaveAttribute(
+ 'href', '/admin/requests'
+ );
});
});