feat(web): add /admin/requests approval queue with override modal
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
<script lang="ts">
|
||||
import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createAdminRequestsQuery,
|
||||
createQualityProfilesQuery,
|
||||
createRootFoldersQuery,
|
||||
approveRequest,
|
||||
rejectRequest
|
||||
} from '$lib/api/admin';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import StatusPill from '$lib/components/StatusPill.svelte';
|
||||
import type { LidarrRequest, LidarrRequestKind, LidarrRequestStatus } from '$lib/api/types';
|
||||
|
||||
// Approval queue. Tabs filter by status; rows expose Override/Approve/Reject
|
||||
// per spec. The override modal collapses by default — most requests get the
|
||||
// snapshot defaults, so we don't make the operator stare at two dropdowns
|
||||
// they almost never touch.
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
// The four tab filters. "Pending" is default — that's the actionable bucket;
|
||||
// the rest are review-only views.
|
||||
const tabs: { status: LidarrRequestStatus; label: string }[] = [
|
||||
{ status: 'pending', label: 'Pending' },
|
||||
{ status: 'approved', label: 'Approved' },
|
||||
{ status: 'completed', label: 'Completed' },
|
||||
{ status: 'rejected', label: 'Rejected' }
|
||||
];
|
||||
|
||||
let activeStatus = $state<LidarrRequestStatus>('pending');
|
||||
|
||||
// The query factory is re-created on every status flip so TanStack treats
|
||||
// each status as its own cache key. We don't need a single shared store.
|
||||
const queryStore = $derived(createAdminRequestsQuery(activeStatus));
|
||||
const query = $derived($queryStore);
|
||||
const rows = $derived((query.data ?? []) as LidarrRequest[]);
|
||||
|
||||
// Quality + root dropdowns for the override modal. Always enabled; if Lidarr
|
||||
// is disabled the modal is unreachable from this page anyway (the queue
|
||||
// would be empty), and on this page we'd rather see the dropdown options
|
||||
// than block the modal on a separate gate.
|
||||
const profilesStore = createQualityProfilesQuery(true);
|
||||
const profiles = $derived($profilesStore);
|
||||
const foldersStore = createRootFoldersQuery(true);
|
||||
const folders = $derived($foldersStore);
|
||||
|
||||
// Modal state. `overrideOpen` and `rejectOpen` carry the request id when
|
||||
// open; null otherwise. The current row is looked up by id at render time.
|
||||
let overrideOpen = $state<string | null>(null);
|
||||
let qualityOverride = $state<number | ''>('');
|
||||
let rootOverride = $state<string>('');
|
||||
|
||||
let rejectOpen = $state<string | null>(null);
|
||||
let rejectNotes = $state<string>('');
|
||||
|
||||
// Toast surface — single line, fixed bottom-right, auto-clears after 5s.
|
||||
// No third-party lib for v1; this is enough to surface the lidarr-unreachable
|
||||
// error per spec §7 without dragging in a toast framework.
|
||||
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 'request_not_pending':
|
||||
return 'This request is no longer pending.';
|
||||
default:
|
||||
return code ? code : "Couldn't reach Lidarr.";
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackIcon(kind: LidarrRequestKind) {
|
||||
if (kind === 'artist') return Disc3;
|
||||
if (kind === 'album') return Album;
|
||||
return Music2;
|
||||
}
|
||||
|
||||
function rowTitle(r: LidarrRequest): string {
|
||||
if (r.kind === 'artist') return r.artist_name;
|
||||
if (r.kind === 'album') return r.album_title ?? '—';
|
||||
return r.track_title ?? '—';
|
||||
}
|
||||
|
||||
function rowAccessibleName(r: LidarrRequest): string {
|
||||
if (r.kind === 'artist') return r.artist_name;
|
||||
if (r.kind === 'album') return r.album_title ?? `this album by ${r.artist_name}`;
|
||||
return r.track_title ?? `this track by ${r.artist_name}`;
|
||||
}
|
||||
|
||||
// The id is a UUID — first 8 chars is plenty to disambiguate without a real
|
||||
// username, and matches what the polish pass will replace with the real
|
||||
// username surface.
|
||||
function rowMeta(r: LidarrRequest): string {
|
||||
const when = new Date(r.requested_at).toLocaleDateString();
|
||||
const userBit = `by user ${r.user_id.slice(0, 8)}`;
|
||||
return `${userBit} · ${when}`;
|
||||
}
|
||||
|
||||
async function invalidate() {
|
||||
// Invalidate every status bucket so e.g. an approve flips a row from the
|
||||
// pending list to the approved list once the operator switches tabs.
|
||||
await client.invalidateQueries({ queryKey: ['adminRequests'] });
|
||||
}
|
||||
|
||||
async function onApprove(
|
||||
r: LidarrRequest,
|
||||
overrides?: { quality_profile_id?: number; root_folder_path?: string }
|
||||
) {
|
||||
try {
|
||||
if (overrides && (overrides.quality_profile_id !== undefined || overrides.root_folder_path !== undefined)) {
|
||||
await approveRequest(r.id, overrides);
|
||||
} else {
|
||||
await approveRequest(r.id);
|
||||
}
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
const code = (e as { code?: string }).code ?? 'unknown';
|
||||
showToast(errorCopy(code));
|
||||
}
|
||||
}
|
||||
|
||||
function openOverride(r: LidarrRequest) {
|
||||
overrideOpen = r.id;
|
||||
// Reset to "Use defaults" each time — operators should consciously opt in
|
||||
// to an override, not inherit the previous row's choice.
|
||||
qualityOverride = '';
|
||||
rootOverride = '';
|
||||
}
|
||||
|
||||
function cancelOverride() {
|
||||
overrideOpen = null;
|
||||
}
|
||||
|
||||
async function confirmOverride(r: LidarrRequest) {
|
||||
const overrides: { quality_profile_id?: number; root_folder_path?: string } = {};
|
||||
if (qualityOverride !== '') overrides.quality_profile_id = Number(qualityOverride);
|
||||
if (rootOverride !== '') overrides.root_folder_path = rootOverride;
|
||||
overrideOpen = null;
|
||||
await onApprove(r, overrides);
|
||||
}
|
||||
|
||||
function openReject(r: LidarrRequest) {
|
||||
rejectOpen = r.id;
|
||||
rejectNotes = '';
|
||||
}
|
||||
|
||||
function cancelReject() {
|
||||
rejectOpen = null;
|
||||
}
|
||||
|
||||
async function confirmReject(r: LidarrRequest) {
|
||||
const notes = rejectNotes.trim();
|
||||
rejectOpen = null;
|
||||
try {
|
||||
await rejectRequest(r.id, notes ? notes : undefined);
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
const code = (e as { code?: string }).code ?? 'unknown';
|
||||
showToast(errorCopy(code));
|
||||
}
|
||||
}
|
||||
|
||||
// Modal lookups: if the active tab has been refetched while a modal is
|
||||
// open, the row may no longer be in `rows`. Guard against that by treating
|
||||
// missing-row as closed.
|
||||
const overrideRow = $derived(
|
||||
overrideOpen ? rows.find((r) => r.id === overrideOpen) ?? null : null
|
||||
);
|
||||
const rejectRow = $derived(
|
||||
rejectOpen ? rows.find((r) => r.id === rejectOpen) ?? null : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Requests</h2>
|
||||
<p class="text-text-secondary">Approve or set aside what users have asked for.</p>
|
||||
</header>
|
||||
|
||||
<nav aria-label="Request status filters" class="border-b border-border">
|
||||
<ul class="flex gap-2">
|
||||
{#each tabs as tab (tab.status)}
|
||||
{@const isActive = activeStatus === tab.status}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
class="border-b-2 px-3 py-2 text-sm transition-colors {isActive
|
||||
? 'border-accent text-text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
||||
onclick={() => (activeStatus = tab.status)}
|
||||
>
|
||||
{tab.label}
|
||||
{#if isActive}
|
||||
<span
|
||||
class="ml-1.5 inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
|
||||
data-testid="active-tab-count"
|
||||
>
|
||||
{rows.length}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{#if query.isPending}
|
||||
<p class="text-text-secondary">Reading the queue…</p>
|
||||
{:else if query.isError}
|
||||
<p class="text-error">Couldn't load requests.</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="text-text-secondary">Nothing here.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each rows as r (r.id)}
|
||||
{@const Icon = fallbackIcon(r.kind)}
|
||||
<li class="flex items-start gap-4 p-3" data-testid="admin-request-row" data-status={r.status}>
|
||||
<div
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon size={24} strokeWidth={1} class="text-text-muted" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="kind-pill">{r.kind}</span>
|
||||
<StatusPill status={r.status} />
|
||||
</div>
|
||||
<div class="truncate text-base font-medium text-text-primary">
|
||||
{rowTitle(r)}
|
||||
</div>
|
||||
<div class="truncate text-sm text-text-secondary">
|
||||
{rowMeta(r)}
|
||||
</div>
|
||||
{#if r.kind === 'track' && r.album_title}
|
||||
<div class="text-sm text-text-secondary" data-testid="track-disclosure">
|
||||
Approving will add the album <em class="font-medium text-text-primary">{r.album_title}</em>.
|
||||
</div>
|
||||
{/if}
|
||||
{#if r.notes}
|
||||
<div class="text-sm text-text-secondary">{r.notes}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if r.status === 'pending'}
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Override ${rowAccessibleName(r)}`}
|
||||
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={() => openOverride(r)}
|
||||
>
|
||||
<SlidersHorizontal size={14} strokeWidth={1.5} />
|
||||
Override
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Approve ${rowAccessibleName(r)}`}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary"
|
||||
onclick={() => onApprove(r)}
|
||||
>
|
||||
<Check size={14} strokeWidth={2} />
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Reject ${rowAccessibleName(r)}`}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
|
||||
onclick={() => openReject(r)}
|
||||
>
|
||||
<X size={14} strokeWidth={2} />
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if overrideRow}
|
||||
<!-- 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={cancelOverride}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="override-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="override-title" class="font-display text-lg font-medium text-text-primary">
|
||||
Approve with override
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-text-secondary">
|
||||
Leave fields blank to use the saved defaults.
|
||||
</p>
|
||||
|
||||
<label class="mt-4 block">
|
||||
<span class="block text-sm text-text-secondary">Quality profile</span>
|
||||
<select
|
||||
bind:value={qualityOverride}
|
||||
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
<option value="">Use default</option>
|
||||
{#each profiles.data ?? [] as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="mt-3 block">
|
||||
<span class="block text-sm text-text-secondary">Root folder</span>
|
||||
<select
|
||||
bind:value={rootOverride}
|
||||
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
<option value="">Use default</option>
|
||||
{#each folders.data ?? [] as f (f.path)}
|
||||
<option value={f.path}>{f.path}{f.accessible ? '' : ' (not accessible)'}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
|
||||
onclick={cancelOverride}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary"
|
||||
onclick={() => confirmOverride(overrideRow!)}
|
||||
>
|
||||
<Check size={14} strokeWidth={2} />
|
||||
Approve with override
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rejectRow}
|
||||
<!-- 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={cancelReject}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reject-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="reject-title" class="font-display text-lg font-medium text-text-primary">
|
||||
Reject request?
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-text-secondary">
|
||||
Notes are shown to the requester.
|
||||
</p>
|
||||
<label class="mt-3 block">
|
||||
<span class="block text-sm text-text-secondary">Notes</span>
|
||||
<textarea
|
||||
bind:value={rejectNotes}
|
||||
rows="3"
|
||||
placeholder="Optional — why this is being set aside"
|
||||
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
|
||||
onclick={cancelReject}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary"
|
||||
onclick={() => confirmReject(rejectRow!)}
|
||||
>
|
||||
<X size={14} strokeWidth={2} />
|
||||
Confirm reject
|
||||
</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>
|
||||
.kind-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);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
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 { LidarrRequest } 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', () => ({
|
||||
createAdminRequestsQuery: vi.fn(),
|
||||
createQualityProfilesQuery: vi.fn(),
|
||||
createRootFoldersQuery: vi.fn(),
|
||||
approveRequest: vi.fn(),
|
||||
rejectRequest: vi.fn()
|
||||
}));
|
||||
|
||||
import AdminRequestsPage from './+page.svelte';
|
||||
import {
|
||||
createAdminRequestsQuery,
|
||||
createQualityProfilesQuery,
|
||||
createRootFoldersQuery,
|
||||
approveRequest,
|
||||
rejectRequest
|
||||
} from '$lib/api/admin';
|
||||
|
||||
const baseRow: LidarrRequest = {
|
||||
id: 'r1',
|
||||
user_id: 'u1234567-aaaa-bbbb-cccc-deadbeef0001',
|
||||
status: 'pending',
|
||||
kind: 'album',
|
||||
lidarr_artist_mbid: 'a-mbid',
|
||||
lidarr_album_mbid: 'al-mbid',
|
||||
artist_name: 'Boards of Canada',
|
||||
album_title: 'Geogaddi',
|
||||
requested_at: '2026-04-29T10:00:00Z',
|
||||
updated_at: '2026-04-29T10:00:00Z'
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(rows: LidarrRequest[] = [baseRow]) {
|
||||
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: rows })
|
||||
);
|
||||
(createQualityProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: [{ id: 1, name: 'Standard' }] })
|
||||
);
|
||||
(createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] })
|
||||
);
|
||||
return render(AdminRequestsPage);
|
||||
}
|
||||
|
||||
describe('/admin/requests', () => {
|
||||
test('Tab switch refetches with new status filter', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /approved/i }));
|
||||
expect(createAdminRequestsQuery).toHaveBeenCalledWith('approved');
|
||||
});
|
||||
|
||||
test('Approve without override fires POST with no override body', async () => {
|
||||
setup();
|
||||
(approveRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /approve geogaddi/i }));
|
||||
expect(approveRequest).toHaveBeenCalledWith('r1');
|
||||
});
|
||||
|
||||
test('Override modal Confirm sends chosen values', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /override geogaddi/i }));
|
||||
await fireEvent.change(screen.getByLabelText(/quality profile/i), {
|
||||
target: { value: '1' }
|
||||
});
|
||||
await fireEvent.change(screen.getByLabelText(/root folder/i), {
|
||||
target: { value: '/music' }
|
||||
});
|
||||
(approveRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
|
||||
await fireEvent.click(
|
||||
screen.getByRole('button', { name: /approve with override/i })
|
||||
);
|
||||
expect(approveRequest).toHaveBeenCalledWith('r1', {
|
||||
quality_profile_id: 1,
|
||||
root_folder_path: '/music'
|
||||
});
|
||||
});
|
||||
|
||||
test('Reject opens notes textarea; confirm sends notes', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /reject geogaddi/i }));
|
||||
const textarea = screen.getByLabelText(/notes/i) as HTMLTextAreaElement;
|
||||
await fireEvent.input(textarea, { target: { value: 'duplicate' } });
|
||||
(rejectRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...baseRow,
|
||||
status: 'rejected'
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole('button', { name: /confirm reject/i })
|
||||
);
|
||||
expect(rejectRequest).toHaveBeenCalledWith('r1', 'duplicate');
|
||||
});
|
||||
|
||||
test('Lidarr-unreachable error shows toast', async () => {
|
||||
setup();
|
||||
(approveRequest as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
|
||||
code: 'lidarr_unreachable',
|
||||
message: 'unreachable',
|
||||
status: 503
|
||||
});
|
||||
await fireEvent.click(screen.getByRole('button', { name: /approve geogaddi/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/lidarr is unreachable/i)).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test('Track-kind row renders disclosure copy about adding the album', () => {
|
||||
setup([
|
||||
{
|
||||
...baseRow,
|
||||
kind: 'track',
|
||||
track_title: 'Roygbiv',
|
||||
album_title: 'Geogaddi'
|
||||
}
|
||||
]);
|
||||
expect(screen.getByText(/will add the album/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user