/** * Component: NZBGet Integration Service Tests * Documentation: documentation/phase3/download-clients.md */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { NZBGetService, getNZBGetService, invalidateNZBGetService } from '@/lib/integrations/nzbget.service'; const clientMock = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), })); const axiosMock = vi.hoisted(() => ({ create: vi.fn(() => clientMock), get: vi.fn(), isAxiosError: vi.fn(() => false), })); const configServiceMock = vi.hoisted(() => ({ get: vi.fn(), })); const downloadClientManagerMock = vi.hoisted(() => ({ getClientForProtocol: vi.fn(), getAllClients: vi.fn(), hasClientForProtocol: vi.fn(), })); vi.mock('axios', () => ({ default: axiosMock, ...axiosMock, })); vi.mock('@/lib/services/config.service', () => ({ getConfigService: vi.fn(async () => configServiceMock), })); vi.mock('@/lib/services/download-client-manager.service', () => ({ getDownloadClientManager: () => downloadClientManagerMock, invalidateDownloadClientManager: vi.fn(), })); describe('NZBGetService', () => { beforeEach(() => { vi.clearAllMocks(); clientMock.get.mockReset(); clientMock.post.mockReset(); axiosMock.get.mockReset(); axiosMock.isAxiosError.mockReset(); axiosMock.isAxiosError.mockReturnValue(false); configServiceMock.get.mockReset(); downloadClientManagerMock.getClientForProtocol.mockReset(); downloadClientManagerMock.getAllClients.mockReset(); downloadClientManagerMock.hasClientForProtocol.mockReset(); invalidateNZBGetService(); }); it('has correct clientType and protocol', () => { const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); expect(service.clientType).toBe('nzbget'); expect(service.protocol).toBe('usenet'); }); // ========================================================================= // Connection Testing // ========================================================================= describe('testConnection', () => { it('returns version when connection succeeds', async () => { clientMock.post.mockResolvedValueOnce({ data: { result: '24.3' }, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const result = await service.testConnection(); expect(result.success).toBe(true); expect(result.version).toBe('24.3'); expect(result.message).toContain('Connected to NZBGet v24.3'); expect(clientMock.post).toHaveBeenCalledWith('/jsonrpc', { method: 'version', params: [], }); }); it('fails when version is empty', async () => { clientMock.post.mockResolvedValueOnce({ data: { result: '' }, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('failed to get NZBGet version'); }); it('returns friendly error on 401 authentication failure', async () => { const authError = new Error('Request failed with status code 401') as any; authError.response = { status: 401 }; authError.isAxiosError = true; axiosMock.isAxiosError.mockReturnValue(true); clientMock.post.mockRejectedValueOnce(authError); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'wrong'); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('Authentication failed'); }); it('returns friendly error on connection refused', async () => { const connError = new Error('connect ECONNREFUSED') as any; connError.code = 'ECONNREFUSED'; connError.isAxiosError = true; axiosMock.isAxiosError.mockReturnValue(true); clientMock.post.mockRejectedValueOnce(connError); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('Connection refused'); }); it('returns friendly error on timeout', async () => { const timeoutError = new Error('timeout of 30000ms exceeded') as any; timeoutError.code = 'ETIMEDOUT'; timeoutError.isAxiosError = true; axiosMock.isAxiosError.mockReturnValue(true); clientMock.post.mockRejectedValueOnce(timeoutError); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('timed out'); }); it('returns SSL error for certificate issues', async () => { clientMock.post.mockRejectedValueOnce(new Error('SSL certificate error')); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('SSL'); }); it('returns RPC error message from server', async () => { clientMock.post.mockResolvedValueOnce({ data: { error: { message: 'Method not found' } }, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('Method not found'); }); }); // ========================================================================= // Adding Downloads // ========================================================================= describe('addDownload', () => { it('downloads NZB file and uploads to NZBGet via append', async () => { // Mock ensureCategory: config() (category already exists) clientMock.post .mockResolvedValueOnce({ data: { result: [ { Name: 'DestDir', Value: '/downloads' }, { Name: 'Category1.Name', Value: 'readmeabook' }, { Name: 'Category1.DestDir', Value: '/downloads' }, ], }, }) // Mock append() .mockResolvedValueOnce({ data: { result: 12345 }, }); // Mock NZB file download axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('fake-nzb-content'), headers: { 'content-disposition': 'attachment; filename="My.Audiobook.nzb"' }, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); const id = await service.addDownload('https://prowlarr.local/api/download/123', { category: 'readmeabook', priority: 'normal', }); expect(id).toBe('12345'); // Verify append call const appendCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'append' ); expect(appendCall).toBeDefined(); const [, body] = appendCall!; expect(body.method).toBe('append'); expect(body.params[0]).toBe('My.Audiobook.nzb'); // Filename expect(body.params[2]).toBe('readmeabook'); // Category expect(body.params[3]).toBe(0); // Normal priority }); it('maps high priority correctly', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [ { Name: 'DestDir', Value: '/downloads' }, { Name: 'Category1.Name', Value: 'readmeabook' }, { Name: 'Category1.DestDir', Value: '/downloads' }, ], }, }) .mockResolvedValueOnce({ data: { result: 99 } }); axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('nzb'), headers: {}, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); const id = await service.addDownload('https://example.com/book.nzb', { priority: 'high' }); expect(id).toBe('99'); const appendCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'append' ); expect(appendCall![1].params[3]).toBe(50); // High = 50 }); it('maps force priority correctly', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [ { Name: 'DestDir', Value: '/downloads' }, { Name: 'Category1.Name', Value: 'readmeabook' }, { Name: 'Category1.DestDir', Value: '/downloads' }, ], }, }) .mockResolvedValueOnce({ data: { result: 100 } }); axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('nzb'), headers: {}, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); await service.addDownload('https://example.com/book.nzb', { priority: 'force' }); const appendCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'append' ); expect(appendCall![1].params[3]).toBe(900); // Force = 900 }); it('throws when NZBGet rejects the NZB', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [ { Name: 'DestDir', Value: '/downloads' }, { Name: 'Category1.Name', Value: 'readmeabook' }, { Name: 'Category1.DestDir', Value: '/downloads' }, ], }, }) .mockResolvedValueOnce({ data: { result: 0 } }); // 0 = rejected axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('nzb'), headers: {}, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); await expect(service.addDownload('https://example.com/bad.nzb')).rejects.toThrow('rejected'); }); it('throws when NZB file download fails with HTTP error', async () => { clientMock.post.mockResolvedValueOnce({ data: { result: [ { Name: 'DestDir', Value: '/downloads' }, { Name: 'Category1.Name', Value: 'readmeabook' }, { Name: 'Category1.DestDir', Value: '/downloads' }, ], }, }); const httpError = new Error('Request failed') as any; httpError.response = { status: 404 }; httpError.isAxiosError = true; axiosMock.isAxiosError.mockReturnValue(true); axiosMock.get.mockRejectedValueOnce(httpError); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); await expect(service.addDownload('https://example.com/missing.nzb')).rejects.toThrow('HTTP 404'); }); it('throws when NZB file is empty', async () => { clientMock.post.mockResolvedValueOnce({ data: { result: [ { Name: 'DestDir', Value: '/downloads' }, { Name: 'Category1.Name', Value: 'readmeabook' }, { Name: 'Category1.DestDir', Value: '/downloads' }, ], }, }); axiosMock.get.mockResolvedValueOnce({ data: Buffer.from(''), headers: {}, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); await expect(service.addDownload('https://example.com/empty.nzb')).rejects.toThrow('empty'); }); it('extracts filename from URL when no Content-Disposition', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [ { Name: 'DestDir', Value: '/downloads' }, { Name: 'Category1.Name', Value: 'readmeabook' }, { Name: 'Category1.DestDir', Value: '/downloads' }, ], }, }) .mockResolvedValueOnce({ data: { result: 50 } }); axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('nzb-content'), headers: {}, // No content-disposition }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); await service.addDownload('https://example.com/My.Great.Audiobook.nzb'); const appendCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'append' ); expect(appendCall![1].params[0]).toBe('My.Great.Audiobook.nzb'); }); it('decompresses gzip-compressed NZB files before uploading', async () => { const zlib = await import('zlib'); const nzbXml = ''; const compressedNzb = zlib.gzipSync(Buffer.from(nzbXml)); clientMock.post .mockResolvedValueOnce({ data: { result: [ { Name: 'DestDir', Value: '/downloads' }, { Name: 'Category1.Name', Value: 'readmeabook' }, { Name: 'Category1.DestDir', Value: '/downloads' }, ], }, }) .mockResolvedValueOnce({ data: { result: 777 } }); axiosMock.get.mockResolvedValueOnce({ data: compressedNzb, headers: { 'content-disposition': 'attachment; filename="Book.nzb"' }, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); const id = await service.addDownload('https://example.com/nzb.gz'); expect(id).toBe('777'); // Verify the base64 content sent to NZBGet is the decompressed XML, not compressed bytes const appendCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'append' ); const sentBase64 = appendCall![1].params[1]; const decodedContent = Buffer.from(sentBase64, 'base64').toString('utf-8'); expect(decodedContent).toBe(nzbXml); }); it('falls back to download.nzb when filename cannot be extracted', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [ { Name: 'DestDir', Value: '/downloads' }, { Name: 'Category1.Name', Value: 'readmeabook' }, { Name: 'Category1.DestDir', Value: '/downloads' }, ], }, }) .mockResolvedValueOnce({ data: { result: 51 } }); axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('nzb-content'), headers: {}, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); await service.addDownload('https://example.com/download'); const appendCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'append' ); expect(appendCall![1].params[0]).toBe('download.nzb'); }); }); // ========================================================================= // Getting Downloads // ========================================================================= describe('getDownload', () => { it('returns queue item when download is active', async () => { // Mock listgroups (queue check) clientMock.post .mockResolvedValueOnce({ data: { result: [ { NZBID: 100, NZBName: 'Active Book', Status: 'DOWNLOADING', FileSizeMB: 500, DownloadedSizeMB: 250, RemainingSizeMB: 250, DownloadTimeSec: 120, Category: 'readmeabook', DestDir: '/downloads/readmeabook/Active.Book', FinalDir: '', MaxPriority: 0, ActiveDownloads: 1, Health: 1000, PostInfoText: '', PostStageProgress: 0, }, ], }, }) // Mock status() for download speed (called inside mapGroupToDownloadInfo) .mockResolvedValueOnce({ data: { result: { DownloadRate: 5242880 } }, // 5 MB/s }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const info = await service.getDownload('100'); expect(info).not.toBeNull(); expect(info!.id).toBe('100'); expect(info!.name).toBe('Active Book'); expect(info!.status).toBe('downloading'); expect(info!.progress).toBe(0.5); expect(info!.size).toBe(500 * 1024 * 1024); expect(info!.bytesDownloaded).toBe(250 * 1024 * 1024); expect(info!.category).toBe('readmeabook'); expect(info!.downloadSpeed).toBe(5242880); }); it('returns history item when download is completed', async () => { // Mock listgroups (empty queue) clientMock.post .mockResolvedValueOnce({ data: { result: [] } }) // Mock history .mockResolvedValueOnce({ data: { result: [ { NZBID: 200, Name: 'Completed Book', Status: 'SUCCESS/ALL', Category: 'readmeabook', FileSizeMB: 300, DownloadedSizeMB: 300, DestDir: '/downloads/readmeabook/Completed.Book', FinalDir: '/downloads/readmeabook/Completed.Book', DownloadTimeSec: 60, PostTotalTimeSec: 30, ParStatus: 'SUCCESS', UnpackStatus: 'SUCCESS', DeleteStatus: 'NONE', MarkStatus: 'NONE', HistoryTime: 1700000000, FailedArticles: 0, TotalArticles: 1000, }, ], }, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const info = await service.getDownload('200'); expect(info).not.toBeNull(); expect(info!.id).toBe('200'); expect(info!.name).toBe('Completed Book'); expect(info!.status).toBe('completed'); expect(info!.progress).toBe(1.0); expect(info!.bytesDownloaded).toBe(300 * 1024 * 1024); expect(info!.completedAt?.getTime()).toBe(1700000000 * 1000); expect(info!.downloadPath).toBe('/downloads/readmeabook/Completed.Book'); }); it('returns history item with failed status and error message', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [] } }) .mockResolvedValueOnce({ data: { result: [ { NZBID: 300, Name: 'Failed Book', Status: 'FAILURE/PAR', Category: 'readmeabook', FileSizeMB: 100, DownloadedSizeMB: 80, DestDir: '/downloads/Failed.Book', FinalDir: '', DownloadTimeSec: 45, PostTotalTimeSec: 10, ParStatus: 'FAILURE', UnpackStatus: 'NONE', DeleteStatus: 'NONE', MarkStatus: 'NONE', HistoryTime: 1700000100, FailedArticles: 50, TotalArticles: 500, }, ], }, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const info = await service.getDownload('300'); expect(info!.status).toBe('failed'); expect(info!.errorMessage).toContain('FAILURE/PAR'); expect(info!.errorMessage).toContain('Par: FAILURE'); expect(info!.errorMessage).toContain('50 failed articles (10%)'); }); it('returns null when download is not found', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [] } }) .mockResolvedValueOnce({ data: { result: [] } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const info = await service.getDownload('999'); expect(info).toBeNull(); }); it('returns null for invalid NZB ID', async () => { const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const info = await service.getDownload('not-a-number'); expect(info).toBeNull(); }); it('applies path mapping to download path', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [] } }) .mockResolvedValueOnce({ data: { result: [ { NZBID: 400, Name: 'Mapped Book', Status: 'SUCCESS/ALL', Category: 'readmeabook', FileSizeMB: 200, DownloadedSizeMB: 200, DestDir: '/remote/downloads/readmeabook/Mapped.Book', FinalDir: '/remote/downloads/readmeabook/Mapped.Book', DownloadTimeSec: 30, PostTotalTimeSec: 15, ParStatus: 'SUCCESS', UnpackStatus: 'SUCCESS', DeleteStatus: 'NONE', MarkStatus: 'NONE', HistoryTime: 1700000200, FailedArticles: 0, TotalArticles: 800, }, ], }, }); const service = new NZBGetService( 'http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads', false, { enabled: true, remotePath: '/remote/downloads', localPath: '/downloads' } ); const info = await service.getDownload('400'); // Path mapping is now applied downstream by consumers, not by the service itself. // The service returns the raw path from NZBGet. const normalizedPath = info!.downloadPath!.replace(/\\/g, '/'); expect(normalizedPath).toContain('/remote/downloads/readmeabook/Mapped.Book'); }); }); // ========================================================================= // Status Mapping // ========================================================================= describe('status mapping', () => { const makeQueueItem = (status: string) => ({ NZBID: 1, NZBName: 'Test', Status: status, FileSizeMB: 100, DownloadedSizeMB: 50, RemainingSizeMB: 50, DownloadTimeSec: 60, Category: '', DestDir: '', FinalDir: '', MaxPriority: 0, ActiveDownloads: 0, Health: 1000, PostInfoText: '', PostStageProgress: 0, }); const makeHistoryItem = (status: string) => ({ NZBID: 1, Name: 'Test', Status: status, Category: '', FileSizeMB: 100, DownloadedSizeMB: 100, DestDir: '', FinalDir: '', DownloadTimeSec: 60, PostTotalTimeSec: 10, ParStatus: 'NONE', UnpackStatus: 'NONE', DeleteStatus: 'NONE', MarkStatus: 'NONE', HistoryTime: 0, FailedArticles: 0, TotalArticles: 100, }); it.each([ ['QUEUED', 'queued'], ['PAUSED', 'paused'], ['DOWNLOADING', 'downloading'], ['FETCHING', 'downloading'], ['PP_QUEUED', 'processing'], ['LOADING_PARS', 'processing'], ['VERIFYING_SOURCES', 'processing'], ['REPAIRING', 'processing'], ['VERIFYING_REPAIRED', 'processing'], ['RENAMING', 'processing'], ['UNPACKING', 'processing'], ['MOVING', 'processing'], ['EXECUTING_SCRIPT', 'processing'], ['PP_FINISHED', 'processing'], ])('maps queue status %s to %s', async (nzbgetStatus, expectedStatus) => { clientMock.post.mockResolvedValueOnce({ data: { result: [makeQueueItem(nzbgetStatus)] }, }); // Mock status() for downloading items if (expectedStatus === 'downloading') { clientMock.post.mockResolvedValueOnce({ data: { result: { DownloadRate: 0 } }, }); } const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const info = await service.getDownload('1'); expect(info!.status).toBe(expectedStatus); }); it.each([ ['SUCCESS/ALL', 'completed'], ['SUCCESS/UNPACK', 'completed'], ['SUCCESS/PAR', 'completed'], ['SUCCESS/HEALTH', 'completed'], ['SUCCESS/GOOD', 'completed'], ['SUCCESS/MARK', 'completed'], ['WARNING/SCRIPT', 'completed'], ['WARNING/SPACE', 'completed'], ['WARNING/PASSWORD', 'completed'], ['WARNING/HEALTH', 'completed'], ['FAILURE/PAR', 'failed'], ['FAILURE/UNPACK', 'failed'], ['FAILURE/HEALTH', 'failed'], ['DELETED/MANUAL', 'failed'], ['DELETED/DUPE', 'failed'], ])('maps history status %s to %s', async (nzbgetStatus, expectedStatus) => { clientMock.post .mockResolvedValueOnce({ data: { result: [] } }) // Empty queue .mockResolvedValueOnce({ data: { result: [makeHistoryItem(nzbgetStatus)] }, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const info = await service.getDownload('1'); expect(info!.status).toBe(expectedStatus); }); it('defaults unknown queue status to downloading', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [makeQueueItem('UNKNOWN_STATUS')] }, }) .mockResolvedValueOnce({ data: { result: { DownloadRate: 0 } }, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const info = await service.getDownload('1'); expect(info!.status).toBe('downloading'); }); }); // ========================================================================= // Pause / Resume / Delete // ========================================================================= describe('pauseDownload', () => { it('calls editqueue with GroupPause', async () => { clientMock.post.mockResolvedValueOnce({ data: { result: true } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); await service.pauseDownload('100'); expect(clientMock.post).toHaveBeenCalledWith('/jsonrpc', { method: 'editqueue', params: ['GroupPause', '', [100]], }); }); it('throws when pause fails', async () => { clientMock.post.mockResolvedValueOnce({ data: { result: false } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); await expect(service.pauseDownload('100')).rejects.toThrow('Failed to pause'); }); }); describe('resumeDownload', () => { it('calls editqueue with GroupResume', async () => { clientMock.post.mockResolvedValueOnce({ data: { result: true } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); await service.resumeDownload('100'); expect(clientMock.post).toHaveBeenCalledWith('/jsonrpc', { method: 'editqueue', params: ['GroupResume', '', [100]], }); }); it('throws when resume fails', async () => { clientMock.post.mockResolvedValueOnce({ data: { result: false } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); await expect(service.resumeDownload('100')).rejects.toThrow('Failed to resume'); }); }); describe('deleteDownload', () => { it('deletes from queue with GroupFinalDelete when deleteFiles is true', async () => { // Mock listgroups to find item in queue clientMock.post .mockResolvedValueOnce({ data: { result: [{ NZBID: 100 }] }, }) // Mock editqueue GroupFinalDelete .mockResolvedValueOnce({ data: { result: true } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); await service.deleteDownload('100', true); const deleteCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'editqueue' ); expect(deleteCall![1].params[0]).toBe('GroupFinalDelete'); }); it('deletes from queue with GroupDelete when deleteFiles is false', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [{ NZBID: 100 }] }, }) .mockResolvedValueOnce({ data: { result: true } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); await service.deleteDownload('100', false); const deleteCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'editqueue' ); expect(deleteCall![1].params[0]).toBe('GroupDelete'); }); it('deletes from history when not in queue', async () => { // Empty queue clientMock.post .mockResolvedValueOnce({ data: { result: [] } }) // Mock editqueue HistoryFinalDelete .mockResolvedValueOnce({ data: { result: true } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); await service.deleteDownload('200', true); const deleteCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'editqueue' ); expect(deleteCall![1].params[0]).toBe('HistoryFinalDelete'); }); it('throws when delete from history fails', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [] } }) .mockResolvedValueOnce({ data: { result: false } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); await expect(service.deleteDownload('999', true)).rejects.toThrow('Failed to delete'); }); }); // ========================================================================= // Post-Process (Archive from History) // ========================================================================= describe('postProcess', () => { it('archives completed download from history via HistoryDelete', async () => { clientMock.post.mockResolvedValueOnce({ data: { result: true } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); await service.postProcess('200'); expect(clientMock.post).toHaveBeenCalledWith('/jsonrpc', { method: 'editqueue', params: ['HistoryDelete', '', [200]], }); }); it('throws when archive fails', async () => { clientMock.post.mockResolvedValueOnce({ data: { result: false } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); await expect(service.postProcess('200')).rejects.toThrow('not found in history or failed to archive'); }); }); // ========================================================================= // Category Management // ========================================================================= describe('ensureCategory', () => { it('creates category and preserves all existing config', async () => { const existingConfig = [ { Name: 'DestDir', Value: '/downloads' }, { Name: 'MainDir', Value: '/root/downloads' }, { Name: 'ServerHost', Value: '0.0.0.0' }, // Read-only entries returned by config() that must NOT be saved back { Name: 'ConfigFile', Value: '/config/nzbget.conf' }, { Name: 'AppBin', Value: '/app/nzbget/nzbget' }, { Name: 'AppDir', Value: '/app/nzbget' }, { Name: 'Version', Value: '26.0' }, ]; clientMock.post // config() .mockResolvedValueOnce({ data: { result: existingConfig } }) // saveconfig() .mockResolvedValueOnce({ data: { result: true } }) // reload() .mockResolvedValueOnce({ data: { result: true } }) // version() poll .mockResolvedValueOnce({ data: { result: '21.1' } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); await service.ensureCategory(); const saveCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'saveconfig' ); expect(saveCall).toBeDefined(); const savedConfig = saveCall![1].params[0]; // Must contain ALL original writable entries (not wiped) expect(savedConfig).toEqual(expect.arrayContaining([ { Name: 'DestDir', Value: '/downloads' }, { Name: 'MainDir', Value: '/root/downloads' }, { Name: 'ServerHost', Value: '0.0.0.0' }, ])); // Plus our new category entries expect(savedConfig).toEqual(expect.arrayContaining([ { Name: 'Category1.Name', Value: 'readmeabook' }, { Name: 'Category1.DestDir', Value: '/downloads' }, { Name: 'Category1.Unpack', Value: 'yes' }, ])); // Read-only entries must NOT be in the saved config const savedNames = savedConfig.map((e: any) => e.Name); expect(savedNames).not.toContain('ConfigFile'); expect(savedNames).not.toContain('AppBin'); expect(savedNames).not.toContain('AppDir'); expect(savedNames).not.toContain('Version'); // Verify reload was called to apply changes const reloadCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'reload' ); expect(reloadCall).toBeDefined(); }); it('uses next available slot and preserves existing categories', async () => { const existingConfig = [ { Name: 'DestDir', Value: '/downloads' }, { Name: 'Category1.Name', Value: 'movies' }, { Name: 'Category1.DestDir', Value: '/downloads/movies' }, { Name: 'Category2.Name', Value: 'tv' }, { Name: 'Category2.DestDir', Value: '/downloads/tv' }, ]; clientMock.post .mockResolvedValueOnce({ data: { result: existingConfig } }) .mockResolvedValueOnce({ data: { result: true } }) // reload + version .mockResolvedValueOnce({ data: { result: true } }) .mockResolvedValueOnce({ data: { result: '21.1' } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); await service.ensureCategory(); const saveCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'saveconfig' ); const savedConfig = saveCall![1].params[0]; // Existing categories preserved expect(savedConfig).toEqual(expect.arrayContaining([ { Name: 'Category1.Name', Value: 'movies' }, { Name: 'Category1.DestDir', Value: '/downloads/movies' }, { Name: 'Category2.Name', Value: 'tv' }, { Name: 'Category2.DestDir', Value: '/downloads/tv' }, ])); // New category in slot 3 expect(savedConfig).toEqual(expect.arrayContaining([ { Name: 'Category3.Name', Value: 'readmeabook' }, { Name: 'Category3.DestDir', Value: '/downloads' }, ])); }); it('does not update when category exists with correct path', async () => { clientMock.post.mockResolvedValueOnce({ data: { result: [ { Name: 'DestDir', Value: '/downloads' }, { Name: 'Category1.Name', Value: 'readmeabook' }, { Name: 'Category1.DestDir', Value: '/downloads' }, ], }, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); await service.ensureCategory(); // Only config() should be called — no saveconfig, no reload expect(clientMock.post).toHaveBeenCalledTimes(1); expect(clientMock.post.mock.calls[0][1].method).toBe('config'); }); it('updates category DestDir preserving full config and reloads', async () => { const existingConfig = [ { Name: 'DestDir', Value: '/downloads' }, { Name: 'ServerHost', Value: '0.0.0.0' }, { Name: 'Category1.Name', Value: 'readmeabook' }, { Name: 'Category1.DestDir', Value: '/old/path' }, // Read-only entries that must be filtered out { Name: 'ConfigFile', Value: '/config/nzbget.conf' }, { Name: 'AppBin', Value: '/app/nzbget/nzbget' }, { Name: 'AppDir', Value: '/app/nzbget' }, { Name: 'Version', Value: '26.0' }, ]; clientMock.post .mockResolvedValueOnce({ data: { result: existingConfig } }) .mockResolvedValueOnce({ data: { result: true } }) // reload + version .mockResolvedValueOnce({ data: { result: true } }) .mockResolvedValueOnce({ data: { result: '21.1' } }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); await service.ensureCategory(); const saveCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'saveconfig' ); const savedConfig = saveCall![1].params[0]; // Full writable config preserved with updated DestDir — no read-only entries expect(savedConfig).toEqual([ { Name: 'DestDir', Value: '/downloads' }, { Name: 'ServerHost', Value: '0.0.0.0' }, { Name: 'Category1.Name', Value: 'readmeabook' }, { Name: 'Category1.DestDir', Value: '/downloads' }, ]); }); it('applies reverse path mapping for category DestDir', async () => { const existingConfig = [ { Name: 'DestDir', Value: '/remote/downloads' }, ]; clientMock.post .mockResolvedValueOnce({ data: { result: existingConfig } }) .mockResolvedValueOnce({ data: { result: true } }) // reload + version .mockResolvedValueOnce({ data: { result: true } }) .mockResolvedValueOnce({ data: { result: '21.1' } }); const service = new NZBGetService( 'http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads', false, { enabled: true, remotePath: '/remote/downloads', localPath: '/downloads' } ); await service.ensureCategory(); const saveCall = clientMock.post.mock.calls.find( (call: any[]) => call[1]?.method === 'saveconfig' ); const savedConfig = saveCall![1].params[0]; // After reverse transform: /downloads → /remote/downloads const destDirEntry = savedConfig.find( (e: any) => e.Name === 'Category1.DestDir' ); expect(destDirEntry.Value).toBe('/remote/downloads'); // Original config preserved expect(savedConfig).toEqual(expect.arrayContaining([ { Name: 'DestDir', Value: '/remote/downloads' }, ])); }); it('continues if reload fails after saveconfig', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [ { Name: 'DestDir', Value: '/downloads' }, ], }, }) .mockResolvedValueOnce({ data: { result: true } }) // reload fails .mockRejectedValueOnce(new Error('Connection reset')); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); // Should not throw — reload failure is handled gracefully await expect(service.ensureCategory()).resolves.toBeUndefined(); }); it('swallows errors when category management fails', async () => { clientMock.post.mockRejectedValueOnce(new Error('Config read failed')); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); // Should not throw await expect(service.ensureCategory()).resolves.toBeUndefined(); }); }); // ========================================================================= // Error Message Building // ========================================================================= describe('error messages', () => { it('builds descriptive error for failed history item with unpack failure', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [] } }) .mockResolvedValueOnce({ data: { result: [ { NZBID: 500, Name: 'Unpack Fail Book', Status: 'FAILURE/UNPACK', Category: '', FileSizeMB: 100, DownloadedSizeMB: 100, DestDir: '', FinalDir: '', DownloadTimeSec: 60, PostTotalTimeSec: 5, ParStatus: 'SUCCESS', UnpackStatus: 'FAILURE', DeleteStatus: 'NONE', MarkStatus: 'NONE', HistoryTime: 0, FailedArticles: 0, TotalArticles: 100, }, ], }, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const info = await service.getDownload('500'); expect(info!.errorMessage).toContain('FAILURE/UNPACK'); expect(info!.errorMessage).toContain('Unpack: FAILURE'); }); it('includes delete status in error message when present', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [] } }) .mockResolvedValueOnce({ data: { result: [ { NZBID: 600, Name: 'Deleted Book', Status: 'DELETED/HEALTH', Category: '', FileSizeMB: 100, DownloadedSizeMB: 50, DestDir: '', FinalDir: '', DownloadTimeSec: 30, PostTotalTimeSec: 0, ParStatus: 'NONE', UnpackStatus: 'NONE', DeleteStatus: 'HEALTH', MarkStatus: 'NONE', HistoryTime: 0, FailedArticles: 100, TotalArticles: 500, }, ], }, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const info = await service.getDownload('600'); expect(info!.errorMessage).toContain('DELETED/HEALTH'); expect(info!.errorMessage).toContain('Delete: HEALTH'); expect(info!.errorMessage).toContain('100 failed articles (20%)'); }); }); // ========================================================================= // Singleton Factory // ========================================================================= describe('singleton factory', () => { it('creates a singleton service from config', async () => { downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-1', type: 'nzbget', name: 'NZBGet', enabled: true, url: 'http://nzbget:6789', username: 'nzbget', password: 'password123', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }); configServiceMock.get.mockResolvedValue('/downloads'); const ensureSpy = vi.spyOn(NZBGetService.prototype, 'ensureCategory').mockResolvedValue(); const service = await getNZBGetService(); const again = await getNZBGetService(); expect(service).toBe(again); // Same instance expect(ensureSpy).toHaveBeenCalled(); ensureSpy.mockRestore(); }); it('creates singleton with path mapping config', async () => { downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-2', type: 'nzbget', name: 'NZBGet', enabled: true, url: 'http://nzbget:6789', username: 'nzbget', password: 'password123', disableSSLVerify: false, remotePathMappingEnabled: true, remotePath: '/remote/downloads', localPath: '/downloads', category: 'readmeabook', }); configServiceMock.get.mockResolvedValue('/downloads'); const ensureSpy = vi.spyOn(NZBGetService.prototype, 'ensureCategory').mockResolvedValue(); const service = await getNZBGetService(); expect(service).toBeDefined(); expect(ensureSpy).toHaveBeenCalled(); ensureSpy.mockRestore(); }); it('throws when no usenet client is configured', async () => { downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null); await expect(getNZBGetService()).rejects.toThrow('not configured'); }); it('throws when configured usenet client is not NZBGet', async () => { downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-3', type: 'sabnzbd', name: 'SABnzbd', enabled: true, url: 'http://sab', password: 'api-key', }); await expect(getNZBGetService()).rejects.toThrow('Expected NZBGet'); }); it('invalidates singleton and recreates on next call', async () => { downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-4', type: 'nzbget', name: 'NZBGet', enabled: true, url: 'http://nzbget:6789', username: 'nzbget', password: 'pass', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }); configServiceMock.get.mockResolvedValue('/downloads'); const ensureSpy = vi.spyOn(NZBGetService.prototype, 'ensureCategory').mockResolvedValue(); const first = await getNZBGetService(); invalidateNZBGetService(); const second = await getNZBGetService(); expect(first).not.toBe(second); ensureSpy.mockRestore(); }); }); // ========================================================================= // Usenet-specific fields // ========================================================================= describe('usenet-specific behavior', () => { it('returns undefined for seeding-related fields', async () => { clientMock.post .mockResolvedValueOnce({ data: { result: [] } }) .mockResolvedValueOnce({ data: { result: [ { NZBID: 700, Name: 'Usenet Book', Status: 'SUCCESS/ALL', Category: 'readmeabook', FileSizeMB: 100, DownloadedSizeMB: 100, DestDir: '/downloads', FinalDir: '', DownloadTimeSec: 30, PostTotalTimeSec: 10, ParStatus: 'SUCCESS', UnpackStatus: 'SUCCESS', DeleteStatus: 'NONE', MarkStatus: 'NONE', HistoryTime: 1700000000, FailedArticles: 0, TotalArticles: 100, }, ], }, }); const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); const info = await service.getDownload('700'); expect(info!.seedingTime).toBeUndefined(); expect(info!.ratio).toBeUndefined(); }); }); });