From b895e9ef7e7522dd95c233f7eca75649da77af8e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 23:24:34 -0400 Subject: [PATCH] feat(web): add /admin/requests approval queue with override modal Co-Authored-By: Claude Opus 4.7 --- web/src/routes/admin/requests/+page.svelte | 444 ++++++++++++++++++ .../routes/admin/requests/requests.test.ts | 130 +++++ 2 files changed, 574 insertions(+) create mode 100644 web/src/routes/admin/requests/+page.svelte create mode 100644 web/src/routes/admin/requests/requests.test.ts diff --git a/web/src/routes/admin/requests/+page.svelte b/web/src/routes/admin/requests/+page.svelte new file mode 100644 index 00000000..f0eb75e3 --- /dev/null +++ b/web/src/routes/admin/requests/+page.svelte @@ -0,0 +1,444 @@ + + +
+
+

Requests

+

Approve or set aside what users have asked for.

+
+ + + + {#if query.isPending} +

Reading the queue…

+ {:else if query.isError} +

Couldn't load requests.

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

Nothing here.

+ {:else} +
    + {#each rows as r (r.id)} + {@const Icon = fallbackIcon(r.kind)} +
  • + + +
    +
    + {r.kind} + +
    +
    + {rowTitle(r)} +
    +
    + {rowMeta(r)} +
    + {#if r.kind === 'track' && r.album_title} +
    + Approving will add the album {r.album_title}. +
    + {/if} + {#if r.notes} +
    {r.notes}
    + {/if} +
    + + {#if r.status === 'pending'} +
    + + + +
    + {/if} +
  • + {/each} +
+ {/if} +
+ +{#if overrideRow} + + +
+ +
+{/if} + +{#if rejectRow} + + +
+ +
+{/if} + +{#if toast} +
+ {toast} +
+{/if} + + diff --git a/web/src/routes/admin/requests/requests.test.ts b/web/src/routes/admin/requests/requests.test.ts new file mode 100644 index 00000000..799d1b0c --- /dev/null +++ b/web/src/routes/admin/requests/requests.test.ts @@ -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; + 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).mockReturnValue( + mockQuery({ data: rows }) + ); + (createQualityProfilesQuery as ReturnType).mockReturnValue( + mockQuery({ data: [{ id: 1, name: 'Standard' }] }) + ); + (createRootFoldersQuery as ReturnType).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).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).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).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).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(); + }); +});