Add backend unit test framework and modularize settings UI

Introduced a Vitest-based backend unit testing framework with supporting scripts, helpers, and GitHub Actions integration. Refactored the admin settings page to a modular architecture, splitting monolithic logic into feature-specific tabs and hooks for improved maintainability and testability. Updated documentation to reflect the new testing setup and settings architecture, and added new dependencies for testing utilities.
This commit is contained in:
kikootwo
2026-01-15 16:49:59 -05:00
parent b3f89d67bb
commit 94dbaf073b
127 changed files with 23549 additions and 2868 deletions
@@ -0,0 +1,329 @@
/**
* Component: Retry Failed Imports Processor Tests
* Documentation: documentation/backend/services/scheduler.md
*/
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
import { createJobQueueMock } from '../helpers/job-queue';
const prismaMock = createPrismaMock();
const jobQueueMock = createJobQueueMock();
const configMock = vi.hoisted(() => ({
getMany: vi.fn(),
get: vi.fn(),
}));
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configMock,
}));
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
getQBittorrentService: () => qbtMock,
}));
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
getSABnzbdService: () => sabnzbdMock,
}));
describe('processRetryFailedImports', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('queues organize jobs using download client paths', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-1',
audiobook: { id: 'a1', title: 'Book' },
downloadHistory: [{ torrentHash: 'hash-1', torrentName: 'Book' }],
},
]);
qbtMock.getTorrent.mockResolvedValue({
save_path: '/downloads',
name: 'Book',
});
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-1' });
expect(result.success).toBe(true);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
'req-1',
'a1',
'/downloads/Book'
);
});
it('returns early when no requests await import', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
});
prismaMock.request.findMany.mockResolvedValue([]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({});
expect(result.success).toBe(true);
expect(result.triggered).toBe(0);
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
});
it('skips requests missing download history', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-2',
audiobook: { id: 'a2', title: 'Book' },
downloadHistory: [],
},
]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-2' });
expect(result.skipped).toBe(1);
expect(result.triggered).toBe(0);
});
it('falls back to configured download dir when qBittorrent lookup fails', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'true',
download_client_remote_path: '/remote',
download_client_local_path: '/downloads',
});
configMock.get.mockResolvedValue('/remote');
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-3',
audiobook: { id: 'a3', title: 'Book' },
downloadHistory: [{ torrentHash: 'hash-3', torrentName: 'Book' }],
},
]);
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-3' });
expect(result.triggered).toBe(1);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
'req-3',
'a3',
path.join('/downloads', 'Book')
);
});
it('uses SABnzbd download path when available', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'true',
download_client_remote_path: '/remote/nzb',
download_client_local_path: '/downloads',
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-4',
audiobook: { id: 'a4', title: 'Book' },
downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'Book' }],
},
]);
sabnzbdMock.getNZB.mockResolvedValue({ downloadPath: '/remote/nzb/Book' });
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-4' });
expect(result.triggered).toBe(1);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
'req-4',
'a4',
path.join('/downloads', 'Book')
);
});
it('skips SABnzbd retries when download dir is missing', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
});
configMock.get.mockResolvedValue(null);
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-5',
audiobook: { id: 'a5', title: 'Book' },
downloadHistory: [{ nzbId: 'nzb-2', torrentName: 'Book' }],
},
]);
sabnzbdMock.getNZB.mockResolvedValue(null);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-5' });
expect(result.skipped).toBe(1);
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
});
it('skips requests with no client identifiers or names', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-6',
audiobook: { id: 'a6', title: 'Book' },
downloadHistory: [{}],
},
]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-6' });
expect(result.skipped).toBe(1);
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
});
it('tracks skipped requests when organize job fails', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-7',
audiobook: { id: 'a7', title: 'Book' },
downloadHistory: [{ torrentHash: 'hash-7', torrentName: 'Book' }],
},
]);
qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' });
jobQueueMock.addOrganizeJob.mockRejectedValue(new Error('queue down'));
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-7' });
expect(result.triggered).toBe(0);
expect(result.skipped).toBe(1);
});
it('skips qBittorrent fallbacks when torrent name is missing', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-8',
audiobook: { id: 'a8', title: 'Book' },
downloadHistory: [{ torrentHash: 'hash-8' }],
},
]);
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-8' });
expect(result.triggered).toBe(0);
expect(result.skipped).toBe(1);
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
});
it('skips qBittorrent fallbacks when download_dir is not configured', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
});
configMock.get.mockResolvedValue(null);
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-9',
audiobook: { id: 'a9', title: 'Book' },
downloadHistory: [{ torrentHash: 'hash-9', torrentName: 'Book' }],
},
]);
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-9' });
expect(result.triggered).toBe(0);
expect(result.skipped).toBe(1);
});
it('skips SABnzbd retries when the client throws', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-10',
audiobook: { id: 'a10', title: 'Book' },
downloadHistory: [{ nzbId: 'nzb-10', torrentName: 'Book' }],
},
]);
sabnzbdMock.getNZB.mockRejectedValue(new Error('sab down'));
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-10' });
expect(result.triggered).toBe(0);
expect(result.skipped).toBe(1);
});
it('skips requests without download_dir when no client identifiers exist', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
});
configMock.get.mockResolvedValue(null);
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-11',
audiobook: { id: 'a11', title: 'Book' },
downloadHistory: [{ torrentName: 'Book' }],
},
]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-11' });
expect(result.triggered).toBe(0);
expect(result.skipped).toBe(1);
});
});