/** * Component: SABnzbd Integration Service Tests * Documentation: documentation/phase3/sabnzbd.md */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SABnzbdService, getSABnzbdService, invalidateSABnzbdService } from '@/lib/integrations/sabnzbd.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(), })); // Mock for DownloadClientManager 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('SABnzbdService', () => { beforeEach(() => { vi.clearAllMocks(); clientMock.get.mockReset(); clientMock.post.mockReset(); axiosMock.get.mockReset(); configServiceMock.get.mockReset(); downloadClientManagerMock.getClientForProtocol.mockReset(); downloadClientManagerMock.getAllClients.mockReset(); downloadClientManagerMock.hasClientForProtocol.mockReset(); invalidateSABnzbdService(); }); it('fails connection when API key is missing', async () => { const service = new SABnzbdService('http://sab', ''); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('API key is required'); expect(clientMock.get).not.toHaveBeenCalled(); }); it('returns a friendly error for invalid API key', async () => { clientMock.get.mockResolvedValueOnce({ data: { status: false, error: 'API Key Incorrect' }, }); const service = new SABnzbdService('http://sab', 'bad-key'); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('Invalid API key'); expect(clientMock.get).toHaveBeenCalledTimes(1); }); it('returns non-API key errors from the server', async () => { clientMock.get.mockResolvedValueOnce({ data: { status: false, error: 'No permissions' }, }); const service = new SABnzbdService('http://sab', 'bad-key'); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toBe('No permissions'); }); it('returns version when connection succeeds', async () => { clientMock.get .mockResolvedValueOnce({ data: { status: true } }) .mockResolvedValueOnce({ data: { version: '4.0.0' } }); const service = new SABnzbdService('http://sab', 'good-key'); const result = await service.testConnection(); expect(result.success).toBe(true); expect(result.version).toBe('4.0.0'); expect(clientMock.get).toHaveBeenCalledTimes(2); }); it('returns SSL error message when certificate issues occur', async () => { clientMock.get.mockRejectedValueOnce(new Error('certificate error')); const service = new SABnzbdService('https://sab', 'key'); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('SSL/TLS certificate error'); }); it('returns a friendly error on connection refused', async () => { clientMock.get.mockRejectedValueOnce(new Error('ECONNREFUSED')); const service = new SABnzbdService('http://sab', 'key'); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('Connection refused'); }); it('adds NZB with mapped priority', async () => { // Mock getConfig for ensureCategory (called before adding NZB) clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { books: { dir: '' } } } }, }); // Mock NZB file download (global axios.get) axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('fake-nzb-content'), headers: {}, }); // Mock addfile upload (POST instead of GET) clientMock.post.mockResolvedValueOnce({ data: { status: true, nzo_ids: ['nzb-1'] }, }); const service = new SABnzbdService('http://sab', 'key', 'books', '/downloads'); const nzbId = await service.addNZB('https://example.com/book.nzb', { category: 'books', priority: 'high', }); expect(nzbId).toBe('nzb-1'); expect(clientMock.post).toHaveBeenCalledTimes(1); }); it('adds NZB with force priority', async () => { // Mock getConfig for ensureCategory (called before adding NZB) clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } }, }); // Mock NZB file download axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('fake-nzb-content'), headers: {}, }); // Mock addfile upload clientMock.post.mockResolvedValueOnce({ data: { status: true, nzo_ids: ['nzb-9'] }, }); const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); const nzbId = await service.addNZB('https://example.com/book.nzb', { priority: 'force' }); expect(nzbId).toBe('nzb-9'); expect(clientMock.post).toHaveBeenCalledTimes(1); }); it('returns queue item info when NZB is active', async () => { clientMock.get.mockResolvedValueOnce({ data: { queue: { slots: [ { nzo_id: 'nzb-2', filename: 'Queue Book', mb: '10', mbleft: '5', percentage: '50', status: 'Paused', timeleft: '0:00:10', cat: 'readmeabook', priority: 'Normal', }, ], }, }, }); const service = new SABnzbdService('http://sab', 'key'); const info = await service.getNZB('nzb-2'); expect(info?.nzbId).toBe('nzb-2'); expect(info?.progress).toBe(0.5); expect(info?.status).toBe('paused'); expect(info?.size).toBe(10 * 1024 * 1024); expect(info?.timeLeft).toBe(10); }); it('maps queue slots from getQueue', async () => { clientMock.get.mockResolvedValueOnce({ data: { queue: { slots: [ { nzo_id: 'nzb-10', filename: 'Queue Book', mb: '5', mbleft: '2', percentage: '40', status: 'Queued', timeleft: '0:01:00', cat: 'readmeabook', priority: 'Normal', }, ], }, }, }); const service = new SABnzbdService('http://sab', 'key'); const queue = await service.getQueue(); expect(queue[0]).toEqual(expect.objectContaining({ nzbId: 'nzb-10', name: 'Queue Book', size: 5, sizeLeft: 2, percentage: 40, status: 'Queued', })); }); it('maps history slots from getHistory', async () => { clientMock.get.mockResolvedValueOnce({ data: { history: { slots: [ { nzo_id: 'nzb-11', name: 'History Book', category: 'readmeabook', status: 'Failed', bytes: '1024', fail_message: 'Failed', storage: '/downloads', completed: '1700000001', download_time: '60', }, ], }, }, }); const service = new SABnzbdService('http://sab', 'key'); const history = await service.getHistory(1); expect(history[0]).toEqual(expect.objectContaining({ nzbId: 'nzb-11', status: 'Failed', bytes: '1024', failMessage: 'Failed', })); }); it('returns history item info when NZB has completed', async () => { clientMock.get .mockResolvedValueOnce({ data: { queue: { slots: [] } } }) .mockResolvedValueOnce({ data: { history: { slots: [ { nzo_id: 'nzb-3', name: 'History Book', category: 'readmeabook', status: 'Completed', bytes: '2048', fail_message: '', storage: '/downloads/book', completed: '1700000000', download_time: '60', }, ], }, }, }); const service = new SABnzbdService('http://sab', 'key'); const info = await service.getNZB('nzb-3'); expect(info?.nzbId).toBe('nzb-3'); expect(info?.progress).toBe(1); expect(info?.status).toBe('completed'); expect(info?.downloadPath).toBe('/downloads/book'); expect(info?.completedAt?.getTime()).toBe(1700000000 * 1000); }); it('returns history item info when NZB has failed', async () => { clientMock.get .mockResolvedValueOnce({ data: { queue: { slots: [] } } }) .mockResolvedValueOnce({ data: { history: { slots: [ { nzo_id: 'nzb-12', name: 'Failed Book', category: 'readmeabook', status: 'Failed', bytes: '2048', fail_message: 'Bad nzb', storage: '/downloads/book', completed: '1700000002', download_time: '30', }, ], }, }, }); const service = new SABnzbdService('http://sab', 'key'); const info = await service.getNZB('nzb-12'); expect(info?.status).toBe('failed'); expect(info?.errorMessage).toBe('Bad nzb'); }); it('maps repairing status in download progress', () => { const service = new SABnzbdService('http://sab', 'key'); const progress = service.getDownloadProgress({ nzbId: 'nzb-4', name: 'Repairing Book', size: 1, sizeLeft: 1, percentage: 100, status: 'Repairing', timeLeft: '0:00:00', category: 'readmeabook', priority: 'Normal', }); expect(progress.state).toBe('repairing'); expect(progress.percent).toBe(1); }); it('maps queued and extracting status in download progress', () => { const service = new SABnzbdService('http://sab', 'key'); const queued = service.getDownloadProgress({ nzbId: 'nzb-5', name: 'Queued Book', size: 2, sizeLeft: 2, percentage: 0, status: 'Queued', timeLeft: '0:10:00', category: 'readmeabook', priority: 'Normal', }); const extracting = service.getDownloadProgress({ nzbId: 'nzb-6', name: 'Extracting Book', size: 2, sizeLeft: 1, percentage: 50, status: 'Extracting', timeLeft: '0:05:00', category: 'readmeabook', priority: 'Normal', }); expect(queued.state).toBe('queued'); expect(extracting.state).toBe('extracting'); }); it('maps completed status when percentage is 100', () => { const service = new SABnzbdService('http://sab', 'key'); const progress = service.getDownloadProgress({ nzbId: 'nzb-7', name: 'Done Book', size: 1, sizeLeft: 0, percentage: 100, status: 'Downloading', timeLeft: '0:00:00', category: 'readmeabook', priority: 'Normal', }); expect(progress.state).toBe('completed'); expect(progress.percent).toBe(1); }); it('creates the default category when missing', async () => { clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/mnt/usenet/complete' }, categories: {} } }, }) .mockResolvedValueOnce({ data: { status: true } }); const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); await service.ensureCategory(); expect(clientMock.get).toHaveBeenCalledWith('/api', expect.objectContaining({ params: expect.objectContaining({ mode: 'set_config', keyword: 'readmeabook' }), })); }); it('swallows errors when ensuring categories fails', async () => { const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); const configSpy = vi.spyOn(service, 'getConfig').mockRejectedValue(new Error('bad config')); await expect(service.ensureCategory()).resolves.toBeUndefined(); configSpy.mockRestore(); }); it('does not create category when it already exists with correct path', async () => { clientMock.get.mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/mnt/usenet/complete' }, categories: { readmeabook: { dir: '/downloads' } }, }, }, }); const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); await service.ensureCategory(); // Only get_config called, no set_config because path already matches expect(clientMock.get).toHaveBeenCalledTimes(1); expect(clientMock.get.mock.calls[0][1].params.mode).toBe('get_config'); }); it('throws when addNZB reports a failure', async () => { // Mock getConfig for ensureCategory, then the upload failure clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } }, }); axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('fake-nzb-content'), headers: {}, }); clientMock.post.mockResolvedValueOnce({ data: { status: false, error: 'Bad NZB' }, }); const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('Bad NZB'); }); it('throws when SABnzbd returns no NZB IDs', async () => { // Mock getConfig for ensureCategory, then the upload with empty IDs clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } }, }); axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('fake-nzb-content'), headers: {}, }); clientMock.post.mockResolvedValueOnce({ data: { status: true, nzo_ids: [] }, }); const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('did not return an NZB ID'); }); it('returns null when NZB is not found in queue or history', async () => { clientMock.get .mockResolvedValueOnce({ data: { queue: { slots: [] } } }) .mockResolvedValueOnce({ data: { history: { slots: [] } } }); const service = new SABnzbdService('http://sab', 'key'); const info = await service.getNZB('missing'); expect(info).toBeNull(); }); it('returns an error message for connection timeouts', async () => { clientMock.get.mockRejectedValueOnce(new Error('ETIMEDOUT')); const service = new SABnzbdService('http://sab', 'key'); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('timed out'); }); it('throws when version is missing from response', async () => { clientMock.get.mockResolvedValueOnce({ data: {} }); const service = new SABnzbdService('http://sab', 'key'); await expect(service.getVersion()).rejects.toThrow('Failed to get SABnzbd version'); }); it('throws when config payload is missing', async () => { clientMock.get.mockResolvedValueOnce({ data: {} }); const service = new SABnzbdService('http://sab', 'key'); await expect(service.getConfig()).rejects.toThrow('Failed to get SABnzbd configuration'); }); it('creates a singleton service from config', async () => { // Mock: SABnzbd client configured via DownloadClientManager downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-1', type: 'sabnzbd', name: 'SABnzbd', enabled: true, url: 'http://sab', password: 'api-key', // API key stored in password field disableSSLVerify: false, remotePathMappingEnabled: false, category: 'books', }); configServiceMock.get.mockResolvedValue('/downloads'); const ensureSpy = vi.spyOn(SABnzbdService.prototype, 'ensureCategory').mockResolvedValue(); const service = await getSABnzbdService(); const again = await getSABnzbdService(); expect(service).toBe(again); expect(ensureSpy).toHaveBeenCalled(); ensureSpy.mockRestore(); }); it('creates singleton with path mapping config when enabled', async () => { downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-1', type: 'sabnzbd', name: 'SABnzbd', enabled: true, url: 'http://sab', password: 'api-key', disableSSLVerify: false, remotePathMappingEnabled: true, remotePath: '/mnt/usenet/complete', localPath: '/downloads', category: 'readmeabook', }); configServiceMock.get.mockResolvedValue('/downloads'); const ensureSpy = vi.spyOn(SABnzbdService.prototype, 'ensureCategory').mockResolvedValue(); const service = await getSABnzbdService(); expect(service).toBeDefined(); expect(ensureSpy).toHaveBeenCalled(); ensureSpy.mockRestore(); }); describe('Path Mapping', () => { it('uses empty category path when download_dir matches complete_dir', async () => { clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: {}, }, }, }) .mockResolvedValueOnce({ data: { status: true } }); const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); await service.ensureCategory(); // Should set empty dir when paths match const setCategoryCall = clientMock.get.mock.calls.find( (call) => call[1]?.params?.mode === 'set_config' ); expect(setCategoryCall).toBeDefined(); expect(setCategoryCall![1].params.dir).toBe(''); }); it('uses relative path when download_dir is under complete_dir', async () => { clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/mnt/usenet/complete' }, categories: {}, }, }, }) .mockResolvedValueOnce({ data: { status: true } }); const service = new SABnzbdService( 'http://sab', 'key', 'readmeabook', '/mnt/usenet/complete/audiobooks' ); await service.ensureCategory(); const setCategoryCall = clientMock.get.mock.calls.find( (call) => call[1]?.params?.mode === 'set_config' ); expect(setCategoryCall).toBeDefined(); expect(setCategoryCall![1].params.dir).toBe('audiobooks'); }); it('uses absolute path when download_dir differs from complete_dir', async () => { clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/mnt/usenet/complete' }, categories: {}, }, }, }) .mockResolvedValueOnce({ data: { status: true } }); const service = new SABnzbdService( 'http://sab', 'key', 'readmeabook', '/different/path/audiobooks' ); await service.ensureCategory(); const setCategoryCall = clientMock.get.mock.calls.find( (call) => call[1]?.params?.mode === 'set_config' ); expect(setCategoryCall).toBeDefined(); expect(setCategoryCall![1].params.dir).toBe('/different/path/audiobooks'); }); it('applies reverse path mapping before comparing with complete_dir', async () => { clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/mnt/usenet/complete' }, categories: {}, }, }, }) .mockResolvedValueOnce({ data: { status: true } }); // RMAB sees /downloads but SABnzbd sees /mnt/usenet/complete const pathMappingConfig = { enabled: true, remotePath: '/mnt/usenet/complete', localPath: '/downloads', }; const service = new SABnzbdService( 'http://sab', 'key', 'readmeabook', '/downloads', // RMAB's local path false, pathMappingConfig ); await service.ensureCategory(); // After reverse transform, /downloads becomes /mnt/usenet/complete // which matches complete_dir, so category dir should be empty const setCategoryCall = clientMock.get.mock.calls.find( (call) => call[1]?.params?.mode === 'set_config' ); expect(setCategoryCall).toBeDefined(); expect(setCategoryCall![1].params.dir).toBe(''); }); it('updates category path when it differs from calculated path', async () => { clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/mnt/usenet/complete' }, categories: { readmeabook: { dir: '/old/path' } }, }, }, }) .mockResolvedValueOnce({ data: { status: true } }); const service = new SABnzbdService( 'http://sab', 'key', 'readmeabook', '/mnt/usenet/complete/audiobooks' ); await service.ensureCategory(); // Should update the category with new relative path const setCategoryCall = clientMock.get.mock.calls.find( (call) => call[1]?.params?.mode === 'set_config' ); expect(setCategoryCall).toBeDefined(); expect(setCategoryCall![1].params.dir).toBe('audiobooks'); }); it('fetches complete_dir from SABnzbd config', async () => { clientMock.get.mockResolvedValueOnce({ data: { config: { version: '4.0.0', misc: { complete_dir: '/mnt/usenet/complete' }, categories: { test: { dir: 'test-dir' } }, }, }, }); const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); const config = await service.getConfig(); expect(config.completeDir).toBe('/mnt/usenet/complete'); expect(config.categories).toEqual([{ name: 'test', dir: 'test-dir' }]); }); it('returns complete_dir via getCompleteDir helper', async () => { clientMock.get.mockResolvedValueOnce({ data: { config: { version: '4.0.0', misc: { complete_dir: '/var/usenet/done' }, categories: {}, }, }, }); const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); const completeDir = await service.getCompleteDir(); expect(completeDir).toBe('/var/usenet/done'); }); it('handles missing complete_dir gracefully', async () => { clientMock.get .mockResolvedValueOnce({ data: { config: { version: '4.0.0', misc: {}, // No complete_dir categories: {}, }, }, }) .mockResolvedValueOnce({ data: { status: true } }); const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); await service.ensureCategory(); // Should fallback to using download_dir directly const setCategoryCall = clientMock.get.mock.calls.find( (call) => call[1]?.params?.mode === 'set_config' ); expect(setCategoryCall).toBeDefined(); expect(setCategoryCall![1].params.dir).toBe('/downloads'); }); it('handles Windows-style paths in path mapping', async () => { clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: 'D:\\Usenet\\Complete' }, categories: {}, }, }, }) .mockResolvedValueOnce({ data: { status: true } }); const pathMappingConfig = { enabled: true, remotePath: 'D:\\Usenet\\Complete', localPath: '/downloads', }; const service = new SABnzbdService( 'http://sab', 'key', 'readmeabook', '/downloads', false, pathMappingConfig ); await service.ensureCategory(); // After reverse transform and comparison (normalized), should match const setCategoryCall = clientMock.get.mock.calls.find( (call) => call[1]?.params?.mode === 'set_config' ); expect(setCategoryCall).toBeDefined(); // Path should be empty since /downloads maps to D:\Usenet\Complete which matches complete_dir expect(setCategoryCall![1].params.dir).toBe(''); }); }); });