mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
94dbaf073b
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.
330 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
|
|
|