Files
ReadMeABook/tests/processors/retry-failed-imports.processor.test.ts
T
kikootwo 94dbaf073b 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.
2026-01-28 11:41:59 -05:00

330 lines
11 KiB
TypeScript

/**
* 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);
});
});