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 @@ -
+

Overview

- + + +
+
+

Pending requests

+ {#if pendingCount > PREVIEW_LIMIT} + View all {pendingCount} → + {/if} +
+ + {#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} +
+ + +
+
+

Quarantine

+ {#if quarantineCount > PREVIEW_LIMIT} + View all {quarantineCount} → + {/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} + +{/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' + ); }); });