mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
4b90b35748
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
758 lines
25 KiB
TypeScript
758 lines
25 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(() => ({
|
|
get: vi.fn(),
|
|
}));
|
|
const downloadClientManagerMock = vi.hoisted(() => ({
|
|
getClientForProtocol: vi.fn(),
|
|
getClientServiceForProtocol: 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/services/download-client-manager.service', () => ({
|
|
getDownloadClientManager: () => downloadClientManagerMock,
|
|
}));
|
|
|
|
describe('processRetryFailedImports', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('queues organize jobs using download client paths', async () => {
|
|
const qbtClientMock = {
|
|
clientType: 'qbittorrent',
|
|
protocol: 'torrent',
|
|
getDownload: vi.fn().mockResolvedValue({
|
|
id: 'hash-1',
|
|
name: 'Book',
|
|
downloadPath: '/downloads/Book',
|
|
progress: 1.0,
|
|
status: 'completed',
|
|
size: 0,
|
|
bytesDownloaded: 0,
|
|
downloadSpeed: 0,
|
|
eta: 0,
|
|
category: 'readmeabook',
|
|
}),
|
|
};
|
|
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-1',
|
|
audiobook: { id: 'a1', title: 'Book' },
|
|
downloadHistory: [{ torrentHash: 'hash-1', torrentName: 'Book', downloadClient: 'qbittorrent' }],
|
|
},
|
|
]);
|
|
|
|
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 () => {
|
|
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 () => {
|
|
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 () => {
|
|
const qbtClientMock = {
|
|
clientType: 'qbittorrent',
|
|
protocol: 'torrent',
|
|
getDownload: vi.fn().mockRejectedValue(new Error('not found')),
|
|
};
|
|
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
remotePathMappingEnabled: true,
|
|
remotePath: '/remote',
|
|
localPath: '/downloads',
|
|
});
|
|
configMock.get.mockResolvedValue('/remote');
|
|
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-3',
|
|
audiobook: { id: 'a3', title: 'Book' },
|
|
downloadHistory: [{ torrentHash: 'hash-3', torrentName: 'Book', downloadClient: 'qbittorrent' }],
|
|
},
|
|
]);
|
|
|
|
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 () => {
|
|
const sabClientMock = {
|
|
clientType: 'sabnzbd',
|
|
protocol: 'usenet',
|
|
getDownload: vi.fn().mockResolvedValue({
|
|
id: 'nzb-1',
|
|
name: 'Book',
|
|
downloadPath: '/remote/nzb/Book',
|
|
progress: 1.0,
|
|
status: 'completed',
|
|
size: 0,
|
|
bytesDownloaded: 0,
|
|
downloadSpeed: 0,
|
|
eta: 0,
|
|
category: 'readmeabook',
|
|
}),
|
|
};
|
|
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-2',
|
|
type: 'sabnzbd',
|
|
name: 'SABnzbd',
|
|
enabled: true,
|
|
remotePathMappingEnabled: true,
|
|
remotePath: '/remote/nzb',
|
|
localPath: '/downloads',
|
|
});
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-4',
|
|
audiobook: { id: 'a4', title: 'Book' },
|
|
downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'Book', downloadClient: 'sabnzbd' }],
|
|
},
|
|
]);
|
|
|
|
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 () => {
|
|
const sabClientMock = {
|
|
clientType: 'sabnzbd',
|
|
protocol: 'usenet',
|
|
getDownload: vi.fn().mockResolvedValue(null),
|
|
};
|
|
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-2',
|
|
type: 'sabnzbd',
|
|
name: 'SABnzbd',
|
|
enabled: true,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
configMock.get.mockResolvedValue(null);
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-5',
|
|
audiobook: { id: 'a5', title: 'Book' },
|
|
downloadHistory: [{ nzbId: 'nzb-2', torrentName: 'Book', downloadClient: 'sabnzbd' }],
|
|
},
|
|
]);
|
|
|
|
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 () => {
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-6',
|
|
audiobook: { id: 'a6', title: 'Book' },
|
|
downloadHistory: [{ downloadClient: 'qbittorrent' }],
|
|
},
|
|
]);
|
|
|
|
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 () => {
|
|
const qbtClientMock = {
|
|
clientType: 'qbittorrent',
|
|
protocol: 'torrent',
|
|
getDownload: vi.fn().mockResolvedValue({
|
|
id: 'hash-7',
|
|
name: 'Book',
|
|
downloadPath: '/downloads/Book',
|
|
progress: 1.0,
|
|
status: 'completed',
|
|
size: 0,
|
|
bytesDownloaded: 0,
|
|
downloadSpeed: 0,
|
|
eta: 0,
|
|
category: 'readmeabook',
|
|
}),
|
|
};
|
|
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-7',
|
|
audiobook: { id: 'a7', title: 'Book' },
|
|
downloadHistory: [{ torrentHash: 'hash-7', torrentName: 'Book', downloadClient: 'qbittorrent' }],
|
|
},
|
|
]);
|
|
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 () => {
|
|
const qbtClientMock = {
|
|
clientType: 'qbittorrent',
|
|
protocol: 'torrent',
|
|
getDownload: vi.fn().mockRejectedValue(new Error('not found')),
|
|
};
|
|
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-8',
|
|
audiobook: { id: 'a8', title: 'Book' },
|
|
downloadHistory: [{ torrentHash: 'hash-8', downloadClient: 'qbittorrent' }],
|
|
},
|
|
]);
|
|
|
|
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 () => {
|
|
const qbtClientMock = {
|
|
clientType: 'qbittorrent',
|
|
protocol: 'torrent',
|
|
getDownload: vi.fn().mockRejectedValue(new Error('not found')),
|
|
};
|
|
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
configMock.get.mockResolvedValue(null);
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-9',
|
|
audiobook: { id: 'a9', title: 'Book' },
|
|
downloadHistory: [{ torrentHash: 'hash-9', torrentName: 'Book', downloadClient: 'qbittorrent' }],
|
|
},
|
|
]);
|
|
|
|
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 () => {
|
|
const sabClientMock = {
|
|
clientType: 'sabnzbd',
|
|
protocol: 'usenet',
|
|
getDownload: vi.fn().mockRejectedValue(new Error('sab down')),
|
|
};
|
|
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-2',
|
|
type: 'sabnzbd',
|
|
name: 'SABnzbd',
|
|
enabled: true,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-10',
|
|
audiobook: { id: 'a10', title: 'Book' },
|
|
downloadHistory: [{ nzbId: 'nzb-10', torrentName: 'Book', downloadClient: 'sabnzbd' }],
|
|
},
|
|
]);
|
|
|
|
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('uses stored downloadPath when client throws', async () => {
|
|
const qbtClientMock = {
|
|
clientType: 'qbittorrent',
|
|
protocol: 'torrent',
|
|
getDownload: vi.fn().mockRejectedValue(new Error('torrent removed')),
|
|
};
|
|
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-stored-1',
|
|
audiobook: { id: 'a-stored-1', title: 'Freefall' },
|
|
downloadHistory: [{
|
|
torrentHash: 'hash-stored-1',
|
|
torrentName: 'Freefall: Expeditionary Force Mavericks, Book 2 - Craig Alanson',
|
|
downloadClient: 'qbittorrent',
|
|
downloadPath: '/downloads/Craig Alanson - Freefall Expeditionary Force Mavericks, Book 2',
|
|
}],
|
|
},
|
|
]);
|
|
|
|
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
|
const result = await processRetryFailedImports({ jobId: 'job-stored-1' });
|
|
|
|
expect(result.triggered).toBe(1);
|
|
// Should use stored path, NOT the torrentName-based fallback
|
|
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
|
'req-stored-1',
|
|
'a-stored-1',
|
|
'/downloads/Craig Alanson - Freefall Expeditionary Force Mavericks, Book 2'
|
|
);
|
|
});
|
|
|
|
it('falls back to torrentName when stored downloadPath is null', async () => {
|
|
const qbtClientMock = {
|
|
clientType: 'qbittorrent',
|
|
protocol: 'torrent',
|
|
getDownload: vi.fn().mockRejectedValue(new Error('torrent removed')),
|
|
};
|
|
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
configMock.get.mockResolvedValue('/downloads');
|
|
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-stored-2',
|
|
audiobook: { id: 'a-stored-2', title: 'Book' },
|
|
downloadHistory: [{
|
|
torrentHash: 'hash-stored-2',
|
|
torrentName: 'Book',
|
|
downloadClient: 'qbittorrent',
|
|
downloadPath: null, // Old record without stored path
|
|
}],
|
|
},
|
|
]);
|
|
|
|
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
|
const result = await processRetryFailedImports({ jobId: 'job-stored-2' });
|
|
|
|
expect(result.triggered).toBe(1);
|
|
// Should fall back to torrentName-based path
|
|
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
|
'req-stored-2',
|
|
'a-stored-2',
|
|
'/downloads/Book'
|
|
);
|
|
});
|
|
|
|
it('prefers live client path over stored downloadPath', async () => {
|
|
const qbtClientMock = {
|
|
clientType: 'qbittorrent',
|
|
protocol: 'torrent',
|
|
getDownload: vi.fn().mockResolvedValue({
|
|
id: 'hash-stored-3',
|
|
name: 'Book',
|
|
downloadPath: '/downloads/LivePath',
|
|
progress: 1.0,
|
|
status: 'completed',
|
|
size: 0,
|
|
bytesDownloaded: 0,
|
|
downloadSpeed: 0,
|
|
eta: 0,
|
|
category: 'readmeabook',
|
|
}),
|
|
};
|
|
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-stored-3',
|
|
audiobook: { id: 'a-stored-3', title: 'Book' },
|
|
downloadHistory: [{
|
|
torrentHash: 'hash-stored-3',
|
|
torrentName: 'Book',
|
|
downloadClient: 'qbittorrent',
|
|
downloadPath: '/downloads/StoredPath',
|
|
}],
|
|
},
|
|
]);
|
|
|
|
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
|
const result = await processRetryFailedImports({ jobId: 'job-stored-3' });
|
|
|
|
expect(result.triggered).toBe(1);
|
|
// Should use live client path, NOT stored path
|
|
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
|
'req-stored-3',
|
|
'a-stored-3',
|
|
'/downloads/LivePath'
|
|
);
|
|
});
|
|
|
|
it('skips requests without download_dir when no client identifiers exist', async () => {
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
configMock.get.mockResolvedValue(null);
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-11',
|
|
audiobook: { id: 'a11', title: 'Book' },
|
|
downloadHistory: [{ torrentName: 'Book', downloadClient: 'qbittorrent' }],
|
|
},
|
|
]);
|
|
|
|
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);
|
|
});
|
|
|
|
// =========================================================================
|
|
// EBOOK REQUEST TESTS
|
|
// =========================================================================
|
|
|
|
it('retries ebook requests with direct download client using stored path', async () => {
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-ebook-1',
|
|
type: 'ebook',
|
|
audiobook: { id: 'a-ebook-1', title: 'Equal Rites' },
|
|
downloadHistory: [{
|
|
downloadClient: 'direct',
|
|
torrentName: 'Equal Rites - Terry Pratchett.epub',
|
|
downloadPath: '/downloads/Equal Rites - Terry Pratchett.epub',
|
|
}],
|
|
},
|
|
]);
|
|
|
|
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
|
const result = await processRetryFailedImports({ jobId: 'job-ebook-1' });
|
|
|
|
expect(result.triggered).toBe(1);
|
|
expect(result.skipped).toBe(0);
|
|
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
|
'req-ebook-1',
|
|
'a-ebook-1',
|
|
'/downloads/Equal Rites - Terry Pratchett.epub'
|
|
);
|
|
});
|
|
|
|
it('retries ebook requests with direct download using fallback path', async () => {
|
|
configMock.get.mockResolvedValue('/downloads');
|
|
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-ebook-2',
|
|
type: 'ebook',
|
|
audiobook: { id: 'a-ebook-2', title: 'Equal Rites' },
|
|
downloadHistory: [{
|
|
downloadClient: 'direct',
|
|
torrentName: 'Equal Rites - Terry Pratchett.epub',
|
|
downloadPath: null, // No stored path
|
|
}],
|
|
},
|
|
]);
|
|
|
|
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
|
const result = await processRetryFailedImports({ jobId: 'job-ebook-2' });
|
|
|
|
expect(result.triggered).toBe(1);
|
|
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
|
'req-ebook-2',
|
|
'a-ebook-2',
|
|
'/downloads/Equal Rites - Terry Pratchett.epub'
|
|
);
|
|
});
|
|
|
|
it('skips direct ebook requests when no stored path and no download_dir', async () => {
|
|
configMock.get.mockResolvedValue(null);
|
|
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-ebook-3',
|
|
type: 'ebook',
|
|
audiobook: { id: 'a-ebook-3', title: 'Equal Rites' },
|
|
downloadHistory: [{
|
|
downloadClient: 'direct',
|
|
torrentName: 'Equal Rites - Terry Pratchett.epub',
|
|
downloadPath: null,
|
|
}],
|
|
},
|
|
]);
|
|
|
|
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
|
const result = await processRetryFailedImports({ jobId: 'job-ebook-3' });
|
|
|
|
expect(result.triggered).toBe(0);
|
|
expect(result.skipped).toBe(1);
|
|
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('retries ebook requests downloaded via indexer (torrent client)', async () => {
|
|
const qbtClientMock = {
|
|
clientType: 'qbittorrent',
|
|
protocol: 'torrent',
|
|
getDownload: vi.fn().mockResolvedValue({
|
|
id: 'hash-ebook-idx',
|
|
name: 'Equal Rites - Terry Pratchett (epub)',
|
|
downloadPath: '/downloads/Equal Rites - Terry Pratchett (epub)',
|
|
progress: 1.0,
|
|
status: 'completed',
|
|
size: 0,
|
|
bytesDownloaded: 0,
|
|
downloadSpeed: 0,
|
|
eta: 0,
|
|
category: 'readmeabook',
|
|
}),
|
|
};
|
|
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-ebook-idx',
|
|
type: 'ebook',
|
|
audiobook: { id: 'a-ebook-idx', title: 'Equal Rites' },
|
|
downloadHistory: [{
|
|
torrentHash: 'hash-ebook-idx',
|
|
torrentName: 'Equal Rites - Terry Pratchett (epub)',
|
|
downloadClient: 'qbittorrent',
|
|
}],
|
|
},
|
|
]);
|
|
|
|
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
|
const result = await processRetryFailedImports({ jobId: 'job-ebook-idx' });
|
|
|
|
expect(result.triggered).toBe(1);
|
|
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
|
'req-ebook-idx',
|
|
'a-ebook-idx',
|
|
'/downloads/Equal Rites - Terry Pratchett (epub)'
|
|
);
|
|
});
|
|
|
|
it('processes mixed audiobook and ebook requests in same batch', async () => {
|
|
const qbtClientMock = {
|
|
clientType: 'qbittorrent',
|
|
protocol: 'torrent',
|
|
getDownload: vi.fn().mockResolvedValue({
|
|
id: 'hash-audio',
|
|
name: 'Gideon the Ninth',
|
|
downloadPath: '/downloads/Gideon the Ninth',
|
|
progress: 1.0,
|
|
status: 'completed',
|
|
size: 0,
|
|
bytesDownloaded: 0,
|
|
downloadSpeed: 0,
|
|
eta: 0,
|
|
category: 'readmeabook',
|
|
}),
|
|
};
|
|
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-mixed-audio',
|
|
type: 'audiobook',
|
|
audiobook: { id: 'a-mixed-audio', title: 'Gideon the Ninth' },
|
|
downloadHistory: [{
|
|
torrentHash: 'hash-audio',
|
|
torrentName: 'Gideon the Ninth',
|
|
downloadClient: 'qbittorrent',
|
|
}],
|
|
},
|
|
{
|
|
id: 'req-mixed-ebook',
|
|
type: 'ebook',
|
|
audiobook: { id: 'a-mixed-ebook', title: 'Equal Rites' },
|
|
downloadHistory: [{
|
|
downloadClient: 'direct',
|
|
torrentName: 'Equal Rites - Terry Pratchett.epub',
|
|
downloadPath: '/downloads/Equal Rites - Terry Pratchett.epub',
|
|
}],
|
|
},
|
|
]);
|
|
|
|
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
|
const result = await processRetryFailedImports({ jobId: 'job-mixed' });
|
|
|
|
expect(result.triggered).toBe(2);
|
|
expect(result.skipped).toBe(0);
|
|
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledTimes(2);
|
|
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
|
'req-mixed-audio',
|
|
'a-mixed-audio',
|
|
'/downloads/Gideon the Ninth'
|
|
);
|
|
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
|
'req-mixed-ebook',
|
|
'a-mixed-ebook',
|
|
'/downloads/Equal Rites - Terry Pratchett.epub'
|
|
);
|
|
});
|
|
|
|
it('skips direct ebook requests with no torrentName and no stored path', async () => {
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-ebook-noname',
|
|
type: 'ebook',
|
|
audiobook: { id: 'a-ebook-noname', title: 'Book' },
|
|
downloadHistory: [{
|
|
downloadClient: 'direct',
|
|
torrentName: null,
|
|
downloadPath: null,
|
|
}],
|
|
},
|
|
]);
|
|
|
|
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
|
const result = await processRetryFailedImports({ jobId: 'job-ebook-noname' });
|
|
|
|
expect(result.triggered).toBe(0);
|
|
expect(result.skipped).toBe(1);
|
|
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
|