/** * Component: Monitor Download Processor Tests * Documentation: documentation/backend/services/jobs.md */ 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 qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn(), getDownloadProgress: vi.fn(), })); const sabMock = vi.hoisted(() => ({ getNZB: vi.fn(), })); const configMock = vi.hoisted(() => ({ getMany: vi.fn(), })); vi.mock('@/lib/db', () => ({ prisma: prismaMock, })); vi.mock('@/lib/services/job-queue.service', () => ({ getJobQueueService: () => jobQueueMock, })); vi.mock('@/lib/integrations/qbittorrent.service', () => ({ getQBittorrentService: () => qbtMock, })); vi.mock('@/lib/integrations/sabnzbd.service', () => ({ getSABnzbdService: () => sabMock, })); vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => configMock, })); describe('processMonitorDownload', () => { beforeEach(() => { vi.clearAllMocks(); }); it('queues organize job when qBittorrent download completes', async () => { qbtMock.getTorrent.mockResolvedValue({ content_path: '/remote/done/Book', save_path: '/remote/done', name: 'Book', }); qbtMock.getDownloadProgress.mockReturnValue({ percent: 100, state: 'completed', speed: 0, eta: 0, }); configMock.getMany.mockResolvedValue({ download_client_remote_path_mapping_enabled: 'true', download_client_remote_path: '/remote/done', download_client_local_path: '/downloads', }); prismaMock.request.update.mockResolvedValue({}); prismaMock.downloadHistory.update.mockResolvedValue({}); prismaMock.request.findFirst.mockResolvedValue({ id: 'req-1', audiobook: { id: 'a1' }, deletedAt: null, }); const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); const result = await processMonitorDownload({ requestId: 'req-1', downloadHistoryId: 'dh-1', downloadClientId: 'hash-1', downloadClient: 'qbittorrent', jobId: 'job-1', }); expect(result.completed).toBe(true); expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( 'req-1', 'a1', expect.stringMatching(/downloads[\\/]+Book/) ); }); it('re-schedules monitoring when download is still active', async () => { qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book', }); qbtMock.getDownloadProgress.mockReturnValue({ percent: 45, state: 'downloading', speed: 100, eta: 60, }); prismaMock.request.update.mockResolvedValue({}); prismaMock.downloadHistory.update.mockResolvedValue({}); const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); const result = await processMonitorDownload({ requestId: 'req-2', downloadHistoryId: 'dh-2', downloadClientId: 'hash-2', downloadClient: 'qbittorrent', jobId: 'job-2', }); expect(result.completed).toBe(false); expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith( 'req-2', 'dh-2', 'hash-2', 'qbittorrent', 10 ); }); it('marks request failed when download fails', async () => { qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book', }); qbtMock.getDownloadProgress.mockReturnValue({ percent: 20, state: 'failed', speed: 0, eta: 0, }); prismaMock.request.update.mockResolvedValue({}); prismaMock.downloadHistory.update.mockResolvedValue({}); const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); const result = await processMonitorDownload({ requestId: 'req-3', downloadHistoryId: 'dh-3', downloadClientId: 'hash-3', downloadClient: 'qbittorrent', jobId: 'job-3', }); expect(result.success).toBe(false); expect(prismaMock.request.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'failed' }), }) ); }); it('handles SABnzbd completion and queues organize job', async () => { sabMock.getNZB.mockResolvedValue({ nzbId: 'nzb-1', size: 100, progress: 1, status: 'completed', downloadSpeed: 0, timeLeft: 0, downloadPath: '/usenet/complete/Book', }); configMock.getMany.mockResolvedValue({ download_client_remote_path_mapping_enabled: 'false', download_client_remote_path: '', download_client_local_path: '', }); prismaMock.request.update.mockResolvedValue({}); prismaMock.downloadHistory.update.mockResolvedValue({}); prismaMock.request.findFirst.mockResolvedValue({ id: 'req-4', audiobook: { id: 'a4' }, deletedAt: null, }); const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); const result = await processMonitorDownload({ requestId: 'req-4', downloadHistoryId: 'dh-4', downloadClientId: 'nzb-1', downloadClient: 'sabnzbd', jobId: 'job-4', }); expect(result.completed).toBe(true); expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( 'req-4', 'a4', '/usenet/complete/Book' ); }); it('does not mark request failed for transient NZB not found errors', async () => { sabMock.getNZB.mockResolvedValue(null); const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); await expect(processMonitorDownload({ requestId: 'req-5', downloadHistoryId: 'dh-5', downloadClientId: 'nzb-missing', downloadClient: 'sabnzbd', jobId: 'job-5', })).rejects.toThrow(/not found/i); expect(prismaMock.request.update).not.toHaveBeenCalled(); }); it('marks request failed when download client is unsupported', async () => { prismaMock.request.update.mockResolvedValue({}); const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); await expect(processMonitorDownload({ requestId: 'req-6', downloadHistoryId: 'dh-6', downloadClientId: 'id-6', downloadClient: 'deluge', jobId: 'job-6', })).rejects.toThrow(/not supported/i); expect(prismaMock.request.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'failed' }), }) ); }); it('marks request failed when SABnzbd completion lacks a download path', async () => { sabMock.getNZB.mockResolvedValue({ nzbId: 'nzb-2', size: 100, progress: 1, status: 'completed', downloadSpeed: 0, timeLeft: 0, downloadPath: undefined, }); prismaMock.request.update.mockResolvedValue({}); prismaMock.downloadHistory.update.mockResolvedValue({}); const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); await expect(processMonitorDownload({ requestId: 'req-7', downloadHistoryId: 'dh-7', downloadClientId: 'nzb-2', downloadClient: 'sabnzbd', jobId: 'job-7', })).rejects.toThrow(/Download path not available/i); expect(prismaMock.request.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'failed' }), }) ); }); });