From ad904afaf665ed75e5329e6cca47485d40ff6f60 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 22:24:17 -0400 Subject: [PATCH] feat(web): add /requests user-facing request history Renders the caller's Lidarr requests as rows with kind pill, StatusPill, and per-status actions: Cancel on pending (which calls cancelRequest then invalidates qk.myRequests()), Listen link on completed (deepest match wins: track > album > artist), admin notes on rejected. Empty state uses the voice-rule "Nothing requested yet." copy. Shell nav gains /requests between /discover and /playlists, visible to all authed users since the view is per-user. Co-Authored-By: Claude Opus 4.7 --- web/src/lib/components/Shell.svelte | 1 + web/src/lib/components/Shell.test.ts | 3 +- web/src/routes/requests/+page.svelte | 140 ++++++++++++++++++++ web/src/routes/requests/requests.test.ts | 159 +++++++++++++++++++++++ 4 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 web/src/routes/requests/+page.svelte create mode 100644 web/src/routes/requests/requests.test.ts diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index b25c5f31..9c83f655 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -26,6 +26,7 @@ { href: '/library/liked', label: 'Liked' }, { href: '/search', label: 'Search' }, { href: '/discover', label: 'Discover' }, + { href: '/requests', label: 'Requests' }, { href: '/playlists', label: 'Playlists' }, { href: '/settings', label: 'Settings' } ]; diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts index cae5eed2..b4489f46 100644 --- a/web/src/lib/components/Shell.test.ts +++ b/web/src/lib/components/Shell.test.ts @@ -28,11 +28,12 @@ describe('Shell', () => { expect(screen.getByText('alice')).toBeInTheDocument(); }); - test('renders nav items including Discover between Search and Playlists', () => { + test('renders nav items including Discover and Requests between Search and Playlists', () => { render(Shell); expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/'); expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search'); expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover'); + expect(screen.getByRole('link', { name: 'Requests' })).toHaveAttribute('href', '/requests'); expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); }); diff --git a/web/src/routes/requests/+page.svelte b/web/src/routes/requests/+page.svelte new file mode 100644 index 00000000..72c0d6b9 --- /dev/null +++ b/web/src/routes/requests/+page.svelte @@ -0,0 +1,140 @@ + + +
+
+

+ Your requests +

+

+ What you've asked Minstrel to add to the library. +

+
+ + {#if query.isError} + + {:else if query.isPending} +

Reading the ledger…

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

Nothing requested yet.

+ {:else} +
    + {#each requests as r (r.id)} + {@const Icon = fallbackIcon(r.kind)} + {@const href = listenHref(r)} +
  • + + +
    +
    + {r.kind} + +
    +
    + {rowTitle(r)} +
    +
    + {rowMeta(r)} +
    + {#if r.status === 'rejected' && r.notes} +
    + {r.notes} +
    + {/if} +
    + +
    + {#if r.status === 'pending'} + + {:else if r.status === 'completed' && href} + + Listen + + {/if} +
    +
  • + {/each} +
+ {/if} +
+ + diff --git a/web/src/routes/requests/requests.test.ts b/web/src/routes/requests/requests.test.ts new file mode 100644 index 00000000..097fd9e9 --- /dev/null +++ b/web/src/routes/requests/requests.test.ts @@ -0,0 +1,159 @@ +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'; + +// Capture invalidateQueries on the mocked QueryClient so the cancel test can +// assert against the same instance the page consumed. +const invalidateQueries = vi.fn().mockResolvedValue(undefined); + +vi.mock('$lib/api/requests', () => ({ + createMyRequestsQuery: vi.fn(), + cancelRequest: vi.fn().mockResolvedValue({ id: 'r1' }) +})); + +vi.mock('$lib/api/queries', () => ({ + qk: { + myRequests: () => ['myRequests'] + } +})); + +vi.mock('$lib/api/client', () => ({ + api: { get: vi.fn(), post: vi.fn(), put: vi.fn(), del: vi.fn() } +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + useQueryClient: () => ({ invalidateQueries }) + }; +}); + +import RequestsPage from './+page.svelte'; +import { createMyRequestsQuery, cancelRequest } from '$lib/api/requests'; +import { qk } from '$lib/api/queries'; + +const mockedCreateMyRequestsQuery = createMyRequestsQuery as ReturnType; +const mockedCancelRequest = cancelRequest as ReturnType; + +function req(over: Partial = {}): LidarrRequest { + return { + id: 'r1', + user_id: 'u1', + status: 'pending', + kind: 'album', + lidarr_artist_mbid: 'art-mbid', + lidarr_album_mbid: 'al-mbid', + lidarr_track_mbid: null, + artist_name: 'Boards of Canada', + album_title: 'Music Has The Right To Children', + track_title: null, + quality_profile_id: null, + root_folder_path: null, + decided_at: null, + decided_by: null, + notes: null, + completed_at: null, + matched_track_id: null, + matched_album_id: null, + matched_artist_id: null, + requested_at: '2026-04-28T12:00:00Z', + updated_at: '2026-04-28T12:00:00Z', + ...over + }; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('Requests page', () => { + test('renders one row per request from the API', () => { + const rows: LidarrRequest[] = [ + req({ id: 'r1', kind: 'artist', artist_name: 'Boards of Canada' }), + req({ id: 'r2', kind: 'album', album_title: 'Geogaddi', status: 'completed' }), + req({ id: 'r3', kind: 'track', track_title: 'Roygbiv', status: 'rejected', notes: 'Already in library' }) + ]; + mockedCreateMyRequestsQuery.mockReturnValue(mockQuery({ data: rows })); + render(RequestsPage); + expect(screen.getAllByTestId('request-row')).toHaveLength(3); + expect(screen.getByText('Boards of Canada')).toBeInTheDocument(); + expect(screen.getByText('Geogaddi')).toBeInTheDocument(); + expect(screen.getByText('Roygbiv')).toBeInTheDocument(); + }); + + test('pending row exposes Cancel; clicking calls cancelRequest and invalidates myRequests', async () => { + const rows: LidarrRequest[] = [ + req({ id: 'r-pending', status: 'pending', kind: 'album', album_title: 'Geogaddi' }) + ]; + mockedCreateMyRequestsQuery.mockReturnValue(mockQuery({ data: rows })); + render(RequestsPage); + const cancelBtn = screen.getByRole('button', { name: /cancel request for geogaddi/i }); + await fireEvent.click(cancelBtn); + await waitFor(() => { + expect(mockedCancelRequest).toHaveBeenCalledWith('r-pending'); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: qk.myRequests() }); + }); + }); + + test('completed row with matched_track_id renders Listen link to /tracks/', () => { + const rows: LidarrRequest[] = [ + req({ + id: 'r-track', + status: 'completed', + kind: 'track', + track_title: 'Roygbiv', + matched_track_id: 'tr-9', + matched_album_id: 'al-9', + matched_artist_id: 'art-9' + }) + ]; + mockedCreateMyRequestsQuery.mockReturnValue(mockQuery({ data: rows })); + render(RequestsPage); + const link = screen.getByRole('link', { name: /listen to roygbiv/i }); + expect(link).toHaveAttribute('href', '/tracks/tr-9'); + }); + + test('completed row with only matched_album_id renders Listen link to /albums/', () => { + const rows: LidarrRequest[] = [ + req({ + id: 'r-album', + status: 'completed', + kind: 'album', + album_title: 'Geogaddi', + matched_track_id: null, + matched_album_id: 'al-7', + matched_artist_id: 'art-7' + }) + ]; + mockedCreateMyRequestsQuery.mockReturnValue(mockQuery({ data: rows })); + render(RequestsPage); + const link = screen.getByRole('link', { name: /listen to geogaddi/i }); + expect(link).toHaveAttribute('href', '/albums/al-7'); + }); + + test('rejected row renders notes and hides Cancel and Listen', () => { + const rows: LidarrRequest[] = [ + req({ + id: 'r-rej', + status: 'rejected', + kind: 'album', + album_title: 'Geogaddi', + notes: 'Already kept in the library.' + }) + ]; + mockedCreateMyRequestsQuery.mockReturnValue(mockQuery({ data: rows })); + render(RequestsPage); + expect(screen.getByTestId('rejection-notes')).toHaveTextContent('Already kept in the library.'); + expect(screen.queryByRole('button', { name: /cancel request/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /listen to/i })).not.toBeInTheDocument(); + }); + + test('empty list shows the voice-rule "Nothing requested yet." copy', () => { + mockedCreateMyRequestsQuery.mockReturnValue(mockQuery({ data: [] })); + render(RequestsPage); + expect(screen.getByText(/nothing requested yet\./i)).toBeInTheDocument(); + expect(screen.queryAllByTestId('request-row')).toHaveLength(0); + }); +});