From 3bfec944c7506d42a82aa58e8c300d23ac03095d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 30 Apr 2026 20:40:34 -0400 Subject: [PATCH] feat(web): /admin/quarantine aggregated queue with resolution actions Co-Authored-By: Claude Opus 4.7 --- web/src/lib/components/AdminSidebar.svelte | 2 +- web/src/lib/components/AdminSidebar.test.ts | 23 +- web/src/routes/admin/quarantine/+page.svelte | 476 ++++++++++++++++++ .../admin/quarantine/quarantine.test.ts | 181 +++++++ 4 files changed, 677 insertions(+), 5 deletions(-) create mode 100644 web/src/routes/admin/quarantine/+page.svelte create mode 100644 web/src/routes/admin/quarantine/quarantine.test.ts diff --git a/web/src/lib/components/AdminSidebar.svelte b/web/src/lib/components/AdminSidebar.svelte index 381395ab..99114878 100644 --- a/web/src/lib/components/AdminSidebar.svelte +++ b/web/src/lib/components/AdminSidebar.svelte @@ -13,7 +13,7 @@ { href: '/admin', label: 'Overview', icon: LayoutGrid }, { href: '/admin/integrations', label: 'Integrations', icon: Plug }, { href: '/admin/requests', label: 'Requests', icon: ListChecks }, - { href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX, placeholder: true }, + { href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX }, { href: '/admin/users', label: 'Users', icon: Users, placeholder: true }, { href: '/admin/library', label: 'Library', icon: FolderTree, placeholder: true } ]; diff --git a/web/src/lib/components/AdminSidebar.test.ts b/web/src/lib/components/AdminSidebar.test.ts index 792913e7..8e73d0ab 100644 --- a/web/src/lib/components/AdminSidebar.test.ts +++ b/web/src/lib/components/AdminSidebar.test.ts @@ -38,14 +38,29 @@ describe('AdminSidebar', () => { ); }); + test('Quarantine link is active when on /admin/quarantine', () => { + state.pageUrl = new URL('http://localhost/admin/quarantine'); + render(AdminSidebar); + expect(screen.getByRole('link', { name: /quarantine/i })).toHaveAttribute( + 'aria-current', + 'page' + ); + }); + + test('Quarantine renders as a real link', () => { + state.pageUrl = new URL('http://localhost/admin'); + render(AdminSidebar); + const link = screen.getByRole('link', { name: /quarantine/i }); + expect(link).toHaveAttribute('href', '/admin/quarantine'); + }); + test('placeholder items render as non-links with aria-disabled', () => { state.pageUrl = new URL('http://localhost/admin'); render(AdminSidebar); - expect(screen.queryByRole('link', { name: /quarantine/i })).not.toBeInTheDocument(); - const quar = screen.getByText(/quarantine/i); - expect(quar.closest('[aria-disabled="true"]')).toBeInTheDocument(); - // Users + Library are also placeholders today. + // Users + Library are still placeholders today. expect(screen.queryByRole('link', { name: /^users$/i })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: /^library$/i })).not.toBeInTheDocument(); + const users = screen.getByText(/^users$/i); + expect(users.closest('[aria-disabled="true"]')).toBeInTheDocument(); }); }); diff --git a/web/src/routes/admin/quarantine/+page.svelte b/web/src/routes/admin/quarantine/+page.svelte new file mode 100644 index 00000000..15cf81b1 --- /dev/null +++ b/web/src/routes/admin/quarantine/+page.svelte @@ -0,0 +1,476 @@ + + +
+
+
+

Quarantine

+ {#if totalReports > 0} + + {totalReports} + + {/if} +
+

User-reported broken tracks waiting for triage.

+
+ + {#if query.isPending} +

Reading the queue…

+ {:else if query.isError} +

Couldn't load the quarantine queue.

+ {:else if rows.length === 0} +

Nothing to triage right now.

+ {:else} +
    + {#each rows as r (r.track_id)} + {@const isExpanded = !!expanded[r.track_id]} + {@const lidarrDisabled = !r.lidarr_album_mbid} +
  • + + + +
    +
    + {r.track_title} +
    +
    + {r.artist_name} · {r.album_title} · {r.report_count} reports — latest {relativeTime(r.latest_at)} +
    + + +
    + {#each Object.entries(r.reason_counts) as [reason, count] (reason)} + + {count}× {REASON_LABELS[reason as LidarrQuarantineReason] ?? reason} + + {/each} +
    + + + + + {#if isExpanded} +
      + {#each r.reports as rep, idx (rep.user_id + ':' + rep.created_at + ':' + idx)} +
    • + {rep.username} + · {REASON_LABELS[rep.reason] ?? rep.reason} + {#if rep.notes} + · {rep.notes} + {/if} + · {relativeTime(rep.created_at)} +
    • + {/each} +
    + {/if} +
    + + +
    + + + + + + + {#if lidarrDisabled} + + + + Delete via Lidarr + + {:else} + + {/if} +
    +
  • + {/each} +
+ {/if} +
+ +{#if deleteFileRow} + + +
+ +
+{/if} + +{#if deleteLidarrRow} + + +
+ +
+{/if} + +{#if toast} +
+ {toast} +
+{/if} + + diff --git a/web/src/routes/admin/quarantine/quarantine.test.ts b/web/src/routes/admin/quarantine/quarantine.test.ts new file mode 100644 index 00000000..68d7eedb --- /dev/null +++ b/web/src/routes/admin/quarantine/quarantine.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../../../test-utils/query'; +import type { AdminQuarantineRow } from '$lib/api/types'; + +// Wrap useQueryClient so the page can call invalidateQueries() without a real +// QueryClient context. Everything else from svelte-query passes through. +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; +}); + +vi.mock('$lib/api/admin', () => ({ + createAdminQuarantineQuery: vi.fn(), + resolveQuarantine: vi.fn(), + deleteQuarantineFile: vi.fn(), + deleteQuarantineViaLidarr: vi.fn() +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playRadio: vi.fn() +})); + +import AdminQuarantinePage from './+page.svelte'; +import { + createAdminQuarantineQuery, + resolveQuarantine, + deleteQuarantineFile, + deleteQuarantineViaLidarr +} from '$lib/api/admin'; +import { playRadio } from '$lib/player/store.svelte'; + +const baseRow: AdminQuarantineRow = { + track_id: 't-001', + track_title: 'Roygbiv', + artist_name: 'Boards of Canada', + album_title: 'Geogaddi', + album_id: 'al-001', + lidarr_album_mbid: 'al-mbid-1', + report_count: 3, + latest_at: new Date(Date.now() - 2 * 3_600_000).toISOString(), + reason_counts: { bad_rip: 2, wrong_tags: 1 }, + reports: [ + { + user_id: 'u1', + username: 'alice', + reason: 'bad_rip', + notes: 'crackles at 1:24', + created_at: new Date(Date.now() - 2 * 3_600_000).toISOString() + }, + { + user_id: 'u2', + username: 'bob', + reason: 'bad_rip', + notes: null, + created_at: new Date(Date.now() - 5 * 3_600_000).toISOString() + }, + { + user_id: 'u3', + username: 'carol', + reason: 'wrong_tags', + notes: 'wrong year', + created_at: new Date(Date.now() - 7 * 3_600_000).toISOString() + } + ] +}; + +afterEach(() => vi.clearAllMocks()); + +function setup(rows: AdminQuarantineRow[] = [baseRow]) { + (createAdminQuarantineQuery as ReturnType).mockReturnValue( + mockQuery({ data: rows }) + ); + return render(AdminQuarantinePage); +} + +describe('/admin/quarantine', () => { + test('Empty state shows the spec copy', () => { + setup([]); + expect(screen.getByText('Nothing to triage right now.')).toBeInTheDocument(); + }); + + test('Aggregated row renders with reason distribution + report count', () => { + setup(); + expect(screen.getByText('Roygbiv')).toBeInTheDocument(); + expect( + screen.getByText(/Boards of Canada · Geogaddi · 3 reports — latest/i) + ).toBeInTheDocument(); + const pills = screen.getAllByTestId('reason-pill').map((el) => el.textContent?.trim()); + expect(pills).toContain('2× Bad rip'); + expect(pills).toContain('1× Wrong tags'); + expect(screen.getByTestId('report-count-pill')).toHaveTextContent('3'); + }); + + test('Resolve fires resolveQuarantine and invalidates the query', async () => { + setup(); + (resolveQuarantine as ReturnType).mockResolvedValueOnce({ + action_id: 'a1', + affected_users: 3 + }); + await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i })); + expect(resolveQuarantine).toHaveBeenCalledWith('t-001'); + }); + + test('Delete file → modal-confirm → fires deleteQuarantineFile', async () => { + setup(); + (deleteQuarantineFile as ReturnType).mockResolvedValueOnce({ + action_id: 'a2', + affected_users: 3 + }); + await fireEvent.click( + screen.getByRole('button', { name: /delete file for roygbiv/i }) + ); + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent(/Remove/i); + expect(dialog).toHaveTextContent(/clear 3 reports/i); + await fireEvent.click( + screen.getByRole('button', { name: /confirm delete file/i }) + ); + expect(deleteQuarantineFile).toHaveBeenCalledWith('t-001'); + }); + + test('Delete via Lidarr → typed-confirm "DELETE" → fires deleteQuarantineViaLidarr', async () => { + setup(); + (deleteQuarantineViaLidarr as ReturnType).mockResolvedValueOnce({ + action_id: 'a3', + affected_users: 3, + deleted_track_count: 12 + }); + await fireEvent.click( + screen.getByRole('button', { name: /delete via lidarr for roygbiv/i }) + ); + await screen.findByRole('dialog'); + const confirmBtn = screen.getByRole('button', { + name: /confirm delete via lidarr/i + }) as HTMLButtonElement; + expect(confirmBtn).toBeDisabled(); + const input = screen.getByLabelText(/type delete to confirm/i) as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'DELETE' } }); + expect(confirmBtn).not.toBeDisabled(); + await fireEvent.click(confirmBtn); + expect(deleteQuarantineViaLidarr).toHaveBeenCalledWith('t-001'); + }); + + test('Lidarr-unreachable error shows toast with spec copy', async () => { + setup(); + (resolveQuarantine as ReturnType).mockRejectedValueOnce({ + code: 'lidarr_unreachable', + message: 'unreachable', + status: 503 + }); + await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i })); + await waitFor(() => + expect( + screen.getByText( + 'Lidarr is unreachable right now. Try again, or check Settings → Integrations.' + ) + ).toBeInTheDocument() + ); + }); + + test('Dimmed Delete-via-Lidarr when lidarr_album_mbid is null', () => { + setup([{ ...baseRow, lidarr_album_mbid: null }]); + const disabled = screen.getByTestId('delete-lidarr-disabled'); + expect(disabled).toHaveClass('cursor-not-allowed'); + expect(disabled).toHaveClass('opacity-50'); + expect(disabled).toHaveAttribute( + 'title', + 'Local-only track — no Lidarr album to remove.' + ); + expect( + screen.queryByRole('button', { name: /delete via lidarr for roygbiv/i }) + ).not.toBeInTheDocument(); + }); + + test('Inline play button calls playRadio with the track id', async () => { + setup(); + await fireEvent.click(screen.getByRole('button', { name: /play roygbiv/i })); + expect(playRadio).toHaveBeenCalledWith('t-001'); + }); +});