feat(web): /admin/quarantine aggregated queue with resolution actions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
<script lang="ts">
|
||||
import { Music2, RotateCcw, Trash2, Cloud, Play, ChevronRight } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createAdminQuarantineQuery,
|
||||
resolveQuarantine,
|
||||
deleteQuarantineFile,
|
||||
deleteQuarantineViaLidarr
|
||||
} from '$lib/api/admin';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { playRadio } from '$lib/player/store.svelte';
|
||||
import type { AdminQuarantineRow, LidarrQuarantineReason } from '$lib/api/types';
|
||||
|
||||
// Aggregated triage queue. One row per track, with per-row resolution
|
||||
// actions: Resolve (clears reports), Delete file (Bronze; modal-confirm),
|
||||
// Delete via Lidarr (Oxblood; typed-confirm "DELETE"). Mirrors
|
||||
// /admin/requests for shape — toast helper, errorCopy(), invalidateQueries.
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
const queryStore = createAdminQuarantineQuery();
|
||||
const query = $derived($queryStore);
|
||||
const rows = $derived((query.data ?? []) as AdminQuarantineRow[]);
|
||||
|
||||
// Sum of reports across all rows; rendered as the accent-tint pill in the
|
||||
// header. Count of *reports*, not unique tracks — that's the spec.
|
||||
const totalReports = $derived(
|
||||
rows.reduce((sum, r) => sum + r.report_count, 0)
|
||||
);
|
||||
|
||||
const REASON_LABELS: Record<LidarrQuarantineReason, string> = {
|
||||
bad_rip: 'Bad rip',
|
||||
wrong_file: 'Wrong file',
|
||||
wrong_tags: 'Wrong tags',
|
||||
duplicate: 'Duplicate',
|
||||
other: 'Other'
|
||||
};
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const days = Math.floor(ms / (24 * 3_600_000));
|
||||
if (days >= 1) return `${days}d ago`;
|
||||
const hours = Math.floor(ms / 3_600_000);
|
||||
if (hours >= 1) return `${hours}h ago`;
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
if (minutes >= 1) return `${minutes}m ago`;
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
// Row-level expand state: tracks which rows have their per-user report
|
||||
// details revealed. Keyed by track_id.
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
|
||||
function toggleExpanded(trackID: string) {
|
||||
expanded[trackID] = !expanded[trackID];
|
||||
}
|
||||
|
||||
// Modal state. The modal-confirm (delete file) and the typed-confirm (delete
|
||||
// via Lidarr) both store the active row's track_id; null when closed.
|
||||
let deleteFileOpen = $state<string | null>(null);
|
||||
|
||||
let deleteLidarrOpen = $state<string | null>(null);
|
||||
let deleteLidarrInput = $state<string>('');
|
||||
// Inline error inside the typed-confirm modal — the modal stays open with
|
||||
// feedback if Lidarr can't be reached or the album lookup fails.
|
||||
let deleteLidarrError = $state<string | null>(null);
|
||||
|
||||
// Toast surface — same pattern as /admin/requests for the lidarr-unreachable
|
||||
// and other error codes from /resolve and /delete-file.
|
||||
let toast = $state<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function showToast(msg: string) {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toast = msg;
|
||||
toastTimer = setTimeout(() => {
|
||||
toast = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function errorCopy(code: string): string {
|
||||
switch (code) {
|
||||
case 'lidarr_unreachable':
|
||||
return "Lidarr is unreachable right now. Try again, or check Settings → Integrations.";
|
||||
case 'lidarr_disabled':
|
||||
return 'Lidarr integration is not enabled.';
|
||||
case 'lidarr_auth_failed':
|
||||
return 'Lidarr authentication failed.';
|
||||
case 'lidarr_album_lookup_failed':
|
||||
return "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.";
|
||||
case 'album_mbid_missing':
|
||||
return 'This track has no Lidarr album to remove.';
|
||||
default:
|
||||
return "Couldn't reach Lidarr.";
|
||||
}
|
||||
}
|
||||
|
||||
async function invalidate() {
|
||||
await client.invalidateQueries({ queryKey: qk.adminQuarantine() });
|
||||
}
|
||||
|
||||
async function onResolve(r: AdminQuarantineRow) {
|
||||
try {
|
||||
await resolveQuarantine(r.track_id);
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
const code = (e as { code?: string }).code ?? 'unknown';
|
||||
showToast(errorCopy(code));
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteFile(r: AdminQuarantineRow) {
|
||||
deleteFileOpen = r.track_id;
|
||||
}
|
||||
|
||||
function cancelDeleteFile() {
|
||||
deleteFileOpen = null;
|
||||
}
|
||||
|
||||
async function confirmDeleteFile(r: AdminQuarantineRow) {
|
||||
deleteFileOpen = null;
|
||||
try {
|
||||
await deleteQuarantineFile(r.track_id);
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
const code = (e as { code?: string }).code ?? 'unknown';
|
||||
showToast(errorCopy(code));
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteLidarr(r: AdminQuarantineRow) {
|
||||
deleteLidarrOpen = r.track_id;
|
||||
deleteLidarrInput = '';
|
||||
deleteLidarrError = null;
|
||||
}
|
||||
|
||||
function cancelDeleteLidarr() {
|
||||
deleteLidarrOpen = null;
|
||||
deleteLidarrInput = '';
|
||||
deleteLidarrError = null;
|
||||
}
|
||||
|
||||
async function confirmDeleteLidarr(r: AdminQuarantineRow) {
|
||||
// Trimmed equality matches the M5a Disconnect typed-confirm pattern.
|
||||
if (deleteLidarrInput.trim() !== 'DELETE') return;
|
||||
try {
|
||||
await deleteQuarantineViaLidarr(r.track_id);
|
||||
deleteLidarrOpen = null;
|
||||
deleteLidarrInput = '';
|
||||
deleteLidarrError = null;
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
const code = (e as { code?: string }).code ?? 'unknown';
|
||||
// Inline error keeps the modal open so the operator sees the failure
|
||||
// alongside the album they were about to remove.
|
||||
deleteLidarrError = errorCopy(code);
|
||||
// Also surface as toast so it's visible after dismissing the modal.
|
||||
showToast(errorCopy(code));
|
||||
}
|
||||
}
|
||||
|
||||
async function onPlay(r: AdminQuarantineRow) {
|
||||
try {
|
||||
await playRadio(r.track_id);
|
||||
} catch {
|
||||
// Silent on failure — the play button is a convenience, not a primary
|
||||
// action. Toast surface is reserved for the destructive action errors.
|
||||
}
|
||||
}
|
||||
|
||||
// Modal lookups guard against the row disappearing if the query refetches
|
||||
// while the modal is open.
|
||||
const deleteFileRow = $derived(
|
||||
deleteFileOpen ? rows.find((r) => r.track_id === deleteFileOpen) ?? null : null
|
||||
);
|
||||
const deleteLidarrRow = $derived(
|
||||
deleteLidarrOpen ? rows.find((r) => r.track_id === deleteLidarrOpen) ?? null : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Quarantine</h2>
|
||||
{#if totalReports > 0}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
|
||||
data-testid="report-count-pill"
|
||||
>
|
||||
{totalReports}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-text-secondary">User-reported broken tracks waiting for triage.</p>
|
||||
</header>
|
||||
|
||||
{#if query.isPending}
|
||||
<p class="text-text-secondary">Reading the queue…</p>
|
||||
{:else if query.isError}
|
||||
<p class="text-error">Couldn't load the quarantine queue.</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="text-text-secondary">Nothing to triage right now.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each rows as r (r.track_id)}
|
||||
{@const isExpanded = !!expanded[r.track_id]}
|
||||
{@const lidarrDisabled = !r.lidarr_album_mbid}
|
||||
<li
|
||||
class="flex items-start gap-4 p-3"
|
||||
data-testid="admin-quarantine-row"
|
||||
data-track-id={r.track_id}
|
||||
>
|
||||
<!-- 56px album art with Slate fallback. -->
|
||||
<div
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if r.album_id}
|
||||
<img
|
||||
src={`/api/albums/${r.album_id}/cover`}
|
||||
alt=""
|
||||
class="h-full w-full rounded-md object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<Music2 size={24} strokeWidth={1} class="text-text-muted" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="truncate text-base font-medium text-text-primary">
|
||||
{r.track_title}
|
||||
</div>
|
||||
<div class="truncate text-sm text-text-secondary">
|
||||
{r.artist_name} · {r.album_title} · {r.report_count} reports — latest {relativeTime(r.latest_at)}
|
||||
</div>
|
||||
|
||||
<!-- Reason distribution pills -->
|
||||
<div class="flex flex-wrap gap-1.5 pt-1">
|
||||
{#each Object.entries(r.reason_counts) as [reason, count] (reason)}
|
||||
<span
|
||||
class="reason-pill"
|
||||
data-testid="reason-pill"
|
||||
>
|
||||
{count}× {REASON_LABELS[reason as LidarrQuarantineReason] ?? reason}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Reports details disclosure -->
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1 inline-flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${isExpanded ? 'Hide' : 'Show'} reports for ${r.track_title}`}
|
||||
onclick={() => toggleExpanded(r.track_id)}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
strokeWidth={1.5}
|
||||
class="transition-transform {isExpanded ? 'rotate-90' : ''}"
|
||||
/>
|
||||
{isExpanded ? 'Hide reports' : `Show ${r.reports.length} reports`}
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
<ul
|
||||
class="mt-2 space-y-1 rounded-md border border-border bg-background p-2 text-xs text-text-secondary"
|
||||
data-testid="reports-details"
|
||||
>
|
||||
{#each r.reports as rep, idx (rep.user_id + ':' + rep.created_at + ':' + idx)}
|
||||
<li>
|
||||
<span class="text-text-primary">{rep.username}</span>
|
||||
· {REASON_LABELS[rep.reason] ?? rep.reason}
|
||||
{#if rep.notes}
|
||||
· <span class="italic">{rep.notes}</span>
|
||||
{/if}
|
||||
· {relativeTime(rep.created_at)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action cluster -->
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Play ${r.track_title}`}
|
||||
class="inline-flex items-center justify-center rounded-md p-2 text-accent hover:bg-accent-tint"
|
||||
onclick={() => onPlay(r)}
|
||||
>
|
||||
<Play size={16} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Resolve ${r.track_title}`}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover"
|
||||
onclick={() => onResolve(r)}
|
||||
>
|
||||
<RotateCcw size={14} strokeWidth={1.5} />
|
||||
Resolve
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Delete file for ${r.track_title}`}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
|
||||
onclick={() => openDeleteFile(r)}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
Delete file
|
||||
</button>
|
||||
|
||||
{#if lidarrDisabled}
|
||||
<span
|
||||
aria-label={`Delete via Lidarr disabled for ${r.track_title}`}
|
||||
title="Local-only track — no Lidarr album to remove."
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary opacity-50 cursor-not-allowed"
|
||||
data-testid="delete-lidarr-disabled"
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
<Cloud size={14} strokeWidth={2} />
|
||||
Delete via Lidarr
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Delete via Lidarr for ${r.track_title}`}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary"
|
||||
onclick={() => openDeleteLidarr(r)}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
<Cloud size={14} strokeWidth={2} />
|
||||
Delete via Lidarr
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if deleteFileRow}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background: rgba(0,0,0,0.5);"
|
||||
onclick={cancelDeleteFile}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-file-title"
|
||||
class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
tabindex="-1"
|
||||
>
|
||||
<h3 id="delete-file-title" class="font-display text-lg font-medium text-text-primary">
|
||||
Delete file?
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-text-secondary">
|
||||
Remove <em class="font-medium text-text-primary">{deleteFileRow.track_title}</em>
|
||||
from disk and clear {deleteFileRow.report_count} reports? Lidarr may auto-redownload depending on its monitor settings.
|
||||
</p>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
|
||||
onclick={cancelDeleteFile}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Confirm delete file"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
|
||||
onclick={() => confirmDeleteFile(deleteFileRow!)}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
Delete file
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if deleteLidarrRow}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background: rgba(0,0,0,0.5);"
|
||||
onclick={cancelDeleteLidarr}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-lidarr-title"
|
||||
class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
tabindex="-1"
|
||||
>
|
||||
<h3 id="delete-lidarr-title" class="font-display text-lg font-medium text-text-primary">
|
||||
Delete via Lidarr?
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-text-secondary">
|
||||
This will tell Lidarr to remove <em class="font-medium text-text-primary">{deleteLidarrRow.album_title}</em>
|
||||
(artist <em class="font-medium text-text-primary">{deleteLidarrRow.artist_name}</em>)
|
||||
and add it to the import-list exclusion. Affects all tracks on the album. Type <strong>DELETE</strong> to confirm.
|
||||
</p>
|
||||
<label class="mt-3 block">
|
||||
<span class="block text-sm text-text-secondary">Type DELETE to confirm</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={deleteLidarrInput}
|
||||
placeholder="DELETE"
|
||||
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
aria-label="Type DELETE to confirm"
|
||||
/>
|
||||
</label>
|
||||
{#if deleteLidarrError}
|
||||
<p class="mt-3 text-sm text-error" data-testid="delete-lidarr-error">
|
||||
{deleteLidarrError}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
|
||||
onclick={cancelDeleteLidarr}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Confirm delete via Lidarr"
|
||||
disabled={deleteLidarrInput.trim() !== 'DELETE'}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => confirmDeleteLidarr(deleteLidarrRow!)}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
<Cloud size={14} strokeWidth={2} />
|
||||
Delete via Lidarr
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if toast}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border border-border bg-surface px-4 py-3 text-sm text-text-primary shadow-lg"
|
||||
data-testid="toast"
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.reason-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
</style>
|
||||
@@ -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<string, unknown>;
|
||||
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user