f77245bc41
After Lidarr accepts a request the local reconciler imports albums and
tracks as they arrive — but until the request hit 'completed' the
operator had no way to see "is anything happening?" The /requests and
/admin/requests rows now surface a live progress line whenever the
request's matched entity has children in our library.
Backend:
- New CountAlbumsByArtist + CountTracksByArtist sqlc queries.
- requestView gains imported_album_count and imported_track_count.
- New fillProgress helper computes them from the matched entity:
- kind=artist → counts albums + tracks under matched_artist_id
- kind=album → counts tracks under matched_album_id
- kind=track → 1 once matched_track_id is set
N+1 in the list endpoints; acceptable at admin scale.
- handleListRequests, handleGetRequest, and handleListAdminRequests
populate the new fields on every response.
Frontend:
- LidarrRequest TS type extended with the two counters.
- Both the operator's /requests page and the admin /admin/requests
page render an accent-colored line under the row meta when at
least one counter is non-zero, e.g.:
Artist: "5 albums · 47 tracks ingested"
Album: "12 tracks ingested"
Track: "Track ingested"
- Updated test fixtures to include the new required fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
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,
|
|
listAdminQuarantine,
|
|
resolveQuarantine,
|
|
deleteQuarantineFile,
|
|
deleteQuarantineViaLidarr,
|
|
listQuarantineActions
|
|
} from './admin';
|
|
import { api } from './client';
|
|
import { qk } from './queries';
|
|
import type {
|
|
ActionResult,
|
|
AdminQuarantineRow,
|
|
LidarrConfig,
|
|
LidarrQualityProfile,
|
|
LidarrQuarantineActionRow,
|
|
LidarrRequest,
|
|
LidarrRootFolder
|
|
} from './types';
|
|
|
|
const baseConfig: LidarrConfig = {
|
|
enabled: true,
|
|
base_url: 'http://lidarr.lan:8686',
|
|
api_key: '***',
|
|
default_quality_profile_id: 1,
|
|
default_metadata_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',
|
|
imported_album_count: 0,
|
|
imported_track_count: 0
|
|
};
|
|
|
|
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 uses "all" when status omitted', () => {
|
|
// listAdminRequests sends no status param when called without one (returns
|
|
// every status), so the cache key must distinguish that from any specific
|
|
// status — otherwise an unfiltered call would falsely cache as `pending`.
|
|
expect(qk.adminRequests()).toEqual(['adminRequests', { status: 'all' }]);
|
|
});
|
|
test('adminRequests key includes the status filter', () => {
|
|
expect(qk.adminRequests('approved')).toEqual([
|
|
'adminRequests',
|
|
{ status: 'approved' }
|
|
]);
|
|
});
|
|
});
|
|
|
|
const baseQuarantineRow: AdminQuarantineRow = {
|
|
track_id: 't1',
|
|
track_title: 'Avril 14th',
|
|
artist_name: 'Aphex Twin',
|
|
album_title: 'Drukqs',
|
|
album_id: 'alb1',
|
|
lidarr_album_mbid: null,
|
|
report_count: 1,
|
|
latest_at: '2026-01-01T00:00:00Z',
|
|
reason_counts: { bad_rip: 1 },
|
|
reports: []
|
|
};
|
|
|
|
const baseAction: ActionResult = {
|
|
action_id: 'a1',
|
|
affected_users: 1
|
|
};
|
|
|
|
const baseActionRow: LidarrQuarantineActionRow = {
|
|
id: 'a1',
|
|
track_id: 't1',
|
|
track_title: 'Avril 14th',
|
|
artist_name: 'Aphex Twin',
|
|
album_title: 'Drukqs',
|
|
action: 'resolved',
|
|
admin_id: 'admin1',
|
|
lidarr_album_mbid: null,
|
|
affected_users: 1,
|
|
created_at: '2026-01-01T00:00:00Z'
|
|
};
|
|
|
|
describe('listAdminQuarantine', () => {
|
|
test('GETs /api/admin/quarantine', async () => {
|
|
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseQuarantineRow]);
|
|
const out = await listAdminQuarantine();
|
|
expect(api.get).toHaveBeenCalledWith('/api/admin/quarantine');
|
|
expect(out).toEqual([baseQuarantineRow]);
|
|
});
|
|
});
|
|
|
|
describe('resolveQuarantine', () => {
|
|
test('POSTs /api/admin/quarantine/:trackID/resolve with empty body', async () => {
|
|
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseAction);
|
|
const out = await resolveQuarantine('t1');
|
|
expect(api.post).toHaveBeenCalledWith('/api/admin/quarantine/t1/resolve', {});
|
|
expect(out).toBe(baseAction);
|
|
});
|
|
});
|
|
|
|
describe('deleteQuarantineFile', () => {
|
|
test('POSTs /api/admin/quarantine/:trackID/delete-file with empty body', async () => {
|
|
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseAction);
|
|
const out = await deleteQuarantineFile('t1');
|
|
expect(api.post).toHaveBeenCalledWith(
|
|
'/api/admin/quarantine/t1/delete-file',
|
|
{}
|
|
);
|
|
expect(out).toBe(baseAction);
|
|
});
|
|
});
|
|
|
|
describe('deleteQuarantineViaLidarr', () => {
|
|
test('POSTs /api/admin/quarantine/:trackID/delete-via-lidarr with empty body', async () => {
|
|
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseAction);
|
|
const out = await deleteQuarantineViaLidarr('t1');
|
|
expect(api.post).toHaveBeenCalledWith(
|
|
'/api/admin/quarantine/t1/delete-via-lidarr',
|
|
{}
|
|
);
|
|
expect(out).toBe(baseAction);
|
|
});
|
|
});
|
|
|
|
describe('listQuarantineActions', () => {
|
|
test('defaults to limit=50 when no argument', async () => {
|
|
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseActionRow]);
|
|
await listQuarantineActions();
|
|
expect(api.get).toHaveBeenCalledWith(
|
|
'/api/admin/quarantine/actions?limit=50'
|
|
);
|
|
});
|
|
|
|
test('forwards explicit limit', async () => {
|
|
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseActionRow]);
|
|
await listQuarantineActions(10);
|
|
expect(api.get).toHaveBeenCalledWith(
|
|
'/api/admin/quarantine/actions?limit=10'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('qk admin quarantine keys', () => {
|
|
test('adminQuarantine key', () => {
|
|
expect(qk.adminQuarantine()).toEqual(['adminQuarantine']);
|
|
});
|
|
test('adminQuarantineActions key defaults to limit 50', () => {
|
|
expect(qk.adminQuarantineActions()).toEqual([
|
|
'adminQuarantineActions',
|
|
{ limit: 50 }
|
|
]);
|
|
});
|
|
test('adminQuarantineActions key reflects custom limit', () => {
|
|
expect(qk.adminQuarantineActions(25)).toEqual([
|
|
'adminQuarantineActions',
|
|
{ limit: 25 }
|
|
]);
|
|
});
|
|
});
|