feat(web): add API client modules for Lidarr, requests, admin
T13 of the M5a Lidarr plan. Three new client modules wrap api.get/post/put/del
helpers and the existing TanStack Query qk namespace:
lidarr.ts - searchLidarr() + createLidarrSearchQuery()
requests.ts - createRequest, listMyRequests, getRequest, cancelRequest;
createMyRequestsQuery(); cancel goes through apiFetch
directly because the backend returns the cancelled row body
(api.del's return type is fixed to null).
admin.ts - getLidarrConfig, putLidarrConfig, testLidarrConnection,
listQualityProfiles, listRootFolders, listAdminRequests,
approveRequest, rejectRequest; query factories for each
read; quality profiles + root folders take an enabled prop
so the call site decides when Lidarr is configured.
Shared LidarrRequestStatus / LidarrRequestKind enums and request/config/
search-result shapes added to types.ts. Per-module helpers (CreateRequestParams)
stay in their module files. testLidarrConnection returns a discriminated union
({ok:true,version} | {ok:false,error}) and never throws on ok:false so the
SPA can render either branch.
qk extended with lidarrSearch, myRequests, lidarrConfig,
lidarrQualityProfiles, lidarrRootFolders, adminRequests.
Tests mirror likes.test.ts (vi.mock('./client')) and cover URL construction,
query-param encoding, body shapes, the not-ok testLidarrConnection branch,
and qk additions. 32 new tests, 217 total passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
del: vi.fn()
|
||||
},
|
||||
apiFetch: vi.fn()
|
||||
}));
|
||||
|
||||
import {
|
||||
createRequest,
|
||||
listMyRequests,
|
||||
getRequest,
|
||||
cancelRequest
|
||||
} from './requests';
|
||||
import { api, apiFetch } from './client';
|
||||
import { qk } from './queries';
|
||||
import type { LidarrRequest } from './types';
|
||||
|
||||
const mockRow: LidarrRequest = {
|
||||
id: 'r1',
|
||||
user_id: 'u1',
|
||||
status: 'pending',
|
||||
kind: 'album',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
lidarr_album_mbid: 'alb-mbid',
|
||||
lidarr_track_mbid: null,
|
||||
artist_name: 'Aphex Twin',
|
||||
album_title: 'Selected Ambient Works',
|
||||
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-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createRequest', () => {
|
||||
test('POSTs /api/requests with full body for an album request', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockRow);
|
||||
const out = await createRequest({
|
||||
kind: 'album',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
lidarr_album_mbid: 'alb-mbid',
|
||||
artist_name: 'Aphex Twin',
|
||||
album_title: 'Selected Ambient Works'
|
||||
});
|
||||
expect(api.post).toHaveBeenCalledWith('/api/requests', {
|
||||
kind: 'album',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
artist_name: 'Aphex Twin',
|
||||
lidarr_album_mbid: 'alb-mbid',
|
||||
album_title: 'Selected Ambient Works'
|
||||
});
|
||||
expect(out).toBe(mockRow);
|
||||
});
|
||||
|
||||
test('omits empty / undefined optional MBID + title fields from wire body', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockRow);
|
||||
await createRequest({
|
||||
kind: 'artist',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
artist_name: 'Aphex Twin',
|
||||
lidarr_album_mbid: '',
|
||||
lidarr_track_mbid: ''
|
||||
});
|
||||
const body = (api.post as ReturnType<typeof vi.fn>).mock.calls[0][1] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body).toEqual({
|
||||
kind: 'artist',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
artist_name: 'Aphex Twin'
|
||||
});
|
||||
expect(body).not.toHaveProperty('lidarr_album_mbid');
|
||||
expect(body).not.toHaveProperty('lidarr_track_mbid');
|
||||
expect(body).not.toHaveProperty('album_title');
|
||||
expect(body).not.toHaveProperty('track_title');
|
||||
});
|
||||
|
||||
test('includes track fields for a track-kind request', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockRow);
|
||||
await createRequest({
|
||||
kind: 'track',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
lidarr_album_mbid: 'alb-mbid',
|
||||
lidarr_track_mbid: 'trk-mbid',
|
||||
artist_name: 'Aphex Twin',
|
||||
album_title: 'Drukqs',
|
||||
track_title: 'Avril 14th'
|
||||
});
|
||||
expect(api.post).toHaveBeenCalledWith('/api/requests', {
|
||||
kind: 'track',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
artist_name: 'Aphex Twin',
|
||||
lidarr_album_mbid: 'alb-mbid',
|
||||
lidarr_track_mbid: 'trk-mbid',
|
||||
album_title: 'Drukqs',
|
||||
track_title: 'Avril 14th'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listMyRequests', () => {
|
||||
test('GETs /api/requests', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([mockRow]);
|
||||
const out = await listMyRequests();
|
||||
expect(api.get).toHaveBeenCalledWith('/api/requests');
|
||||
expect(out).toEqual([mockRow]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRequest', () => {
|
||||
test('GETs /api/requests/:id', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockRow);
|
||||
const out = await getRequest('r1');
|
||||
expect(api.get).toHaveBeenCalledWith('/api/requests/r1');
|
||||
expect(out).toBe(mockRow);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelRequest', () => {
|
||||
test('DELETEs /api/requests/:id and returns the cancelled row', async () => {
|
||||
const cancelled = { ...mockRow, status: 'rejected' as const };
|
||||
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cancelled);
|
||||
const out = await cancelRequest('r1');
|
||||
expect(apiFetch).toHaveBeenCalledWith('/api/requests/r1', { method: 'DELETE' });
|
||||
expect(out).toBe(cancelled);
|
||||
expect(out.status).toBe('rejected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('qk.myRequests', () => {
|
||||
test('returns the expected key tuple', () => {
|
||||
expect(qk.myRequests()).toEqual(['myRequests']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user