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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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<typeof vi.fn>;
|
||||
const mockedCancelRequest = cancelRequest as ReturnType<typeof vi.fn>;
|
||||
|
||||
function req(over: Partial<LidarrRequest> = {}): 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<LidarrRequest[]>({ 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<LidarrRequest[]>({ 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/<id>', () => {
|
||||
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<LidarrRequest[]>({ 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/<id>', () => {
|
||||
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<LidarrRequest[]>({ 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<LidarrRequest[]>({ 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<LidarrRequest[]>({ data: [] }));
|
||||
render(RequestsPage);
|
||||
expect(screen.getByText(/nothing requested yet\./i)).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('request-row')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user