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,227 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
del: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import {
|
||||
getLidarrConfig,
|
||||
putLidarrConfig,
|
||||
testLidarrConnection,
|
||||
listQualityProfiles,
|
||||
listRootFolders,
|
||||
listAdminRequests,
|
||||
approveRequest,
|
||||
rejectRequest
|
||||
} from './admin';
|
||||
import { api } from './client';
|
||||
import { qk } from './queries';
|
||||
import type {
|
||||
LidarrConfig,
|
||||
LidarrQualityProfile,
|
||||
LidarrRequest,
|
||||
LidarrRootFolder
|
||||
} from './types';
|
||||
|
||||
const baseConfig: LidarrConfig = {
|
||||
enabled: true,
|
||||
base_url: 'http://lidarr.lan:8686',
|
||||
api_key: '***',
|
||||
default_quality_profile_id: 1,
|
||||
default_root_folder_path: '/music'
|
||||
};
|
||||
|
||||
const baseRow: 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: 'Drukqs',
|
||||
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();
|
||||
});
|
||||
|
||||
describe('getLidarrConfig', () => {
|
||||
test('GETs /api/admin/lidarr/config', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseConfig);
|
||||
const out = await getLidarrConfig();
|
||||
expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/config');
|
||||
expect(out).toBe(baseConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('putLidarrConfig', () => {
|
||||
test('PUTs /api/admin/lidarr/config with the full config body', async () => {
|
||||
const next: LidarrConfig = { ...baseConfig, api_key: 'plain-key' };
|
||||
(api.put as ReturnType<typeof vi.fn>).mockResolvedValueOnce(next);
|
||||
const out = await putLidarrConfig(next);
|
||||
expect(api.put).toHaveBeenCalledWith('/api/admin/lidarr/config', next);
|
||||
expect(out).toBe(next);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testLidarrConnection', () => {
|
||||
test('POSTs /api/admin/lidarr/test with empty body when no overrides', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
version: '2.0.0'
|
||||
});
|
||||
const out = await testLidarrConnection();
|
||||
expect(api.post).toHaveBeenCalledWith('/api/admin/lidarr/test', {});
|
||||
expect(out).toEqual({ ok: true, version: '2.0.0' });
|
||||
});
|
||||
|
||||
test('forwards override base_url + api_key body', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
version: '2.0.0'
|
||||
});
|
||||
await testLidarrConnection({
|
||||
base_url: 'http://other:8686',
|
||||
api_key: 'try-this'
|
||||
});
|
||||
expect(api.post).toHaveBeenCalledWith('/api/admin/lidarr/test', {
|
||||
base_url: 'http://other:8686',
|
||||
api_key: 'try-this'
|
||||
});
|
||||
});
|
||||
|
||||
test('returns ok:false branch without throwing', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: 'auth failed'
|
||||
});
|
||||
const out = await testLidarrConnection();
|
||||
expect(out).toEqual({ ok: false, error: 'auth failed' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('listQualityProfiles', () => {
|
||||
test('GETs /api/admin/lidarr/quality-profiles', async () => {
|
||||
const profiles: LidarrQualityProfile[] = [{ id: 1, name: 'Lossless' }];
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(profiles);
|
||||
const out = await listQualityProfiles();
|
||||
expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/quality-profiles');
|
||||
expect(out).toBe(profiles);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRootFolders', () => {
|
||||
test('GETs /api/admin/lidarr/root-folders', async () => {
|
||||
const folders: LidarrRootFolder[] = [
|
||||
{ path: '/music', accessible: true, free_space: 100 }
|
||||
];
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(folders);
|
||||
const out = await listRootFolders();
|
||||
expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/root-folders');
|
||||
expect(out).toBe(folders);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAdminRequests', () => {
|
||||
test('no args -> /api/admin/requests with no query string', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseRow]);
|
||||
await listAdminRequests();
|
||||
expect(api.get).toHaveBeenCalledWith('/api/admin/requests');
|
||||
});
|
||||
|
||||
test('status only -> ?status=', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseRow]);
|
||||
await listAdminRequests('approved');
|
||||
expect(api.get).toHaveBeenCalledWith('/api/admin/requests?status=approved');
|
||||
});
|
||||
|
||||
test('status + limit -> ?status=&limit=', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseRow]);
|
||||
await listAdminRequests('pending', 25);
|
||||
expect(api.get).toHaveBeenCalledWith(
|
||||
'/api/admin/requests?status=pending&limit=25'
|
||||
);
|
||||
});
|
||||
|
||||
test('limit only -> ?limit=', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseRow]);
|
||||
await listAdminRequests(undefined, 10);
|
||||
expect(api.get).toHaveBeenCalledWith('/api/admin/requests?limit=10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('approveRequest', () => {
|
||||
test('POSTs to /api/admin/requests/:id/approve with empty body by default', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
|
||||
await approveRequest('r1');
|
||||
expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/approve', {});
|
||||
});
|
||||
|
||||
test('forwards quality_profile_id + root_folder_path overrides', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
|
||||
await approveRequest('r1', {
|
||||
quality_profile_id: 2,
|
||||
root_folder_path: '/music'
|
||||
});
|
||||
expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/approve', {
|
||||
quality_profile_id: 2,
|
||||
root_folder_path: '/music'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejectRequest', () => {
|
||||
test('POSTs to /api/admin/requests/:id/reject with empty body when no notes', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
|
||||
await rejectRequest('r1');
|
||||
expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/reject', {});
|
||||
});
|
||||
|
||||
test('includes notes when provided', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
|
||||
await rejectRequest('r1', 'duplicate of r0');
|
||||
expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/reject', {
|
||||
notes: 'duplicate of r0'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('qk admin keys', () => {
|
||||
test('lidarrConfig key', () => {
|
||||
expect(qk.lidarrConfig()).toEqual(['lidarrConfig']);
|
||||
});
|
||||
test('lidarrQualityProfiles key', () => {
|
||||
expect(qk.lidarrQualityProfiles()).toEqual(['lidarrQualityProfiles']);
|
||||
});
|
||||
test('lidarrRootFolders key', () => {
|
||||
expect(qk.lidarrRootFolders()).toEqual(['lidarrRootFolders']);
|
||||
});
|
||||
test('adminRequests key defaults to pending when status omitted', () => {
|
||||
expect(qk.adminRequests()).toEqual(['adminRequests', { status: 'pending' }]);
|
||||
});
|
||||
test('adminRequests key includes the status filter', () => {
|
||||
expect(qk.adminRequests('approved')).toEqual([
|
||||
'adminRequests',
|
||||
{ status: 'approved' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user