/** * Component: Prowlarr Integration Service Tests * Documentation: documentation/phase3/prowlarr.md */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ProwlarrService } from '@/lib/integrations/prowlarr.service'; const clientMock = vi.hoisted(() => ({ get: vi.fn(), interceptors: { request: { use: vi.fn(), }, }, })); const axiosMock = vi.hoisted(() => ({ create: vi.fn(() => clientMock), get: vi.fn(), })); const configMock = vi.hoisted(() => ({ get: vi.fn(), getMany: 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: () => configMock, })); vi.mock('@/lib/services/download-client-manager.service', () => ({ getDownloadClientManager: () => downloadClientManagerMock, invalidateDownloadClientManager: vi.fn(), })); describe('ProwlarrService', () => { beforeEach(() => { vi.clearAllMocks(); clientMock.get.mockReset(); axiosMock.get.mockReset(); configMock.get.mockReset(); downloadClientManagerMock.getClientForProtocol.mockReset(); downloadClientManagerMock.getAllClients.mockReset(); downloadClientManagerMock.hasClientForProtocol.mockReset(); }); it('filters results for SABnzbd (usenet)', async () => { // Mock: Only SABnzbd is configured (usenet only) downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => { return protocol === 'usenet'; }); clientMock.get.mockResolvedValue({ data: [ { guid: 'g1', indexer: 'IndexerA', title: 'Book NZB', size: 100, publishDate: '2024-01-01T00:00:00.000Z', downloadUrl: 'https://example.com/book.nzb', protocol: 'usenet', }, { guid: 'g2', indexer: 'IndexerB', title: 'Book Torrent', size: 200, publishDate: '2024-01-02T00:00:00.000Z', magnetUrl: 'magnet:?xt=urn:btih:abc', protocol: 'torrent', }, ], }); const service = new ProwlarrService('http://prowlarr', 'key'); const results = await service.search('Book'); expect(results).toHaveLength(1); expect(results[0].downloadUrl).toContain('.nzb'); expect(results[0].protocol).toBe('usenet'); }); it('throws when search fails', async () => { // Mock: qBittorrent is configured (torrent only) downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => { return protocol === 'torrent'; }); clientMock.get.mockRejectedValue(new Error('bad search')); const service = new ProwlarrService('http://prowlarr', 'key'); await expect(service.search('Book')).rejects.toThrow('Failed to search Prowlarr: bad search'); }); it('filters results for qBittorrent (torrent)', async () => { // Mock: Only qBittorrent is configured (torrent only) downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => { return protocol === 'torrent'; }); clientMock.get.mockResolvedValue({ data: [ { guid: 'g1', indexer: 'IndexerA', title: 'Book NZB', size: 100, publishDate: '2024-01-01T00:00:00.000Z', downloadUrl: 'https://example.com/book.nzb', protocol: 'usenet', }, { guid: 'g2', indexer: 'IndexerB', title: 'Book Torrent', size: 200, publishDate: '2024-01-02T00:00:00.000Z', magnetUrl: 'magnet:?xt=urn:btih:abc', protocol: 'torrent', }, ], }); const service = new ProwlarrService('http://prowlarr', 'key'); const results = await service.search('Book'); expect(results).toHaveLength(1); expect(results[0].downloadUrl).toContain('magnet:?'); expect(results[0].protocol).toBe('torrent'); }); it('parses RSS feeds into torrent results', async () => { const xml = ` Great Book M4B 64kbps https://example.com/book.torrent guid-1 Mon, 01 Jan 2024 00:00:00 GMT IndexerA `; axiosMock.get.mockResolvedValue({ data: xml }); const service = new ProwlarrService('http://prowlarr', 'key'); const results = await service.getRssFeed(1); expect(results).toHaveLength(1); expect(results[0].seeders).toBe(5); expect(results[0].leechers).toBe(3); expect(results[0].format).toBe('M4B'); expect(results[0].bitrate).toBe('64kbps'); expect(results[0].hasChapters).toBe(true); }); it('skips RSS items missing download URLs', async () => { const xml = ` Book Without Link guid-2 Mon, 01 Jan 2024 00:00:00 GMT IndexerA `; axiosMock.get.mockResolvedValue({ data: xml }); const service = new ProwlarrService('http://prowlarr', 'key'); const results = await service.getRssFeed(2); expect(results).toHaveLength(0); }); it('detects NZB downloads by protocol or URL', () => { expect(ProwlarrService.isNZBResult({ downloadUrl: 'https://x/test.nzb' } as any)).toBe(true); expect(ProwlarrService.isNZBResult({ downloadUrl: 'https://x/getnzb?id=1' } as any)).toBe(true); expect(ProwlarrService.isNZBResult({ downloadUrl: 'magnet:?xt=urn:btih:abc' } as any)).toBe(false); expect(ProwlarrService.isNZBResult({ downloadUrl: 'https://x/file', protocol: 'usenet' } as any)).toBe(true); }); it('applies category, indexer, and seeder filters', async () => { // Mock: Only qBittorrent is configured (torrent only) downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => { return protocol === 'torrent'; }); clientMock.get.mockResolvedValue({ data: [ { guid: 'g1', indexer: 'IndexerA', title: 'Book One', size: 100, publishDate: '2024-01-01T00:00:00.000Z', downloadUrl: 'https://example.com/book.torrent', protocol: 'torrent', seeders: 1, }, { guid: 'g2', indexer: 'IndexerB', title: 'Book Two', size: 200, publishDate: '2024-01-02T00:00:00.000Z', downloadUrl: 'https://example.com/book2.torrent', protocol: 'torrent', seeders: 10, }, ], }); const service = new ProwlarrService('http://prowlarr', 'key'); const results = await service.search('Book', { categories: [3030, 3040], minSeeders: 2, maxResults: 1, indexerIds: [1, 2], }); expect(results).toHaveLength(1); expect(results[0].title).toBe('Book Two'); expect(clientMock.get).toHaveBeenCalledWith('/search', { params: expect.objectContaining({ categories: [3030, 3040], indexerIds: [1, 2], }), }); }); it('returns unfiltered results when protocol filtering fails', async () => { // Mock: hasClientForProtocol throws an error downloadClientManagerMock.hasClientForProtocol.mockRejectedValue(new Error('config fail')); clientMock.get.mockResolvedValue({ data: [ { guid: 'g1', indexer: 'IndexerA', title: 'Book NZB', size: 100, publishDate: '2024-01-01T00:00:00.000Z', downloadUrl: 'https://example.com/book.nzb', protocol: 'usenet', }, { guid: 'g2', indexer: 'IndexerB', title: 'Book Torrent', size: 200, publishDate: '2024-01-02T00:00:00.000Z', downloadUrl: 'https://example.com/book.torrent', protocol: 'torrent', }, ], }); const service = new ProwlarrService('http://prowlarr', 'key'); const results = await service.search('Book'); expect(results).toHaveLength(2); }); it('aggregates RSS feeds and ignores failures', async () => { const service = new ProwlarrService('http://prowlarr', 'key'); const rssSpy = vi.spyOn(service, 'getRssFeed') .mockRejectedValueOnce(new Error('bad')) .mockResolvedValueOnce([{ guid: 'g1' } as any]); const results = await service.getAllRssFeeds([1, 2]); expect(rssSpy).toHaveBeenCalledTimes(2); expect(results).toHaveLength(1); }); it('skips results without download URLs', () => { const service = new ProwlarrService('http://prowlarr', 'key'); const result = (service as any).transformResult({ guid: 'g1', indexer: 'IndexerA', title: 'No URL', size: 100, publishDate: '2024-01-01T00:00:00.000Z', }); expect(result).toBeNull(); }); it('extracts flags from indexer fields and title metadata', () => { const service = new ProwlarrService('http://prowlarr', 'key'); const result = (service as any).transformResult({ guid: 'g3', indexer: 'IndexerA', title: 'Book M4A 128kbps', size: 100, publishDate: '2024-01-01T00:00:00.000Z', downloadUrl: 'https://example.com/book.torrent', indexerFlags: ['Trusted', 2], flags: ['Featured', 'Trusted'], }); expect(result?.flags).toEqual(['Trusted', 'Featured']); expect(result?.format).toBe('M4A'); expect(result?.bitrate).toBe('128kbps'); }); it('derives flags from volume factors when no explicit flags exist', () => { const service = new ProwlarrService('http://prowlarr', 'key'); const result = (service as any).transformResult({ guid: 'g4', indexer: 'IndexerB', title: 'Book MP3', size: 100, publishDate: '2024-01-01T00:00:00.000Z', downloadUrl: 'https://example.com/book.torrent', downloadVolumeFactor: 0, uploadVolumeFactor: 2, }); expect(result?.flags).toEqual(['Freeleech', 'Double Upload']); expect(result?.format).toBe('MP3'); }); it('marks partial freeleech when download volume factor is reduced', () => { const service = new ProwlarrService('http://prowlarr', 'key'); const result = (service as any).transformResult({ guid: 'g5', indexer: 'IndexerC', title: 'Book MP3', size: 100, publishDate: '2024-01-01T00:00:00.000Z', downloadUrl: 'https://example.com/book.torrent', downloadVolumeFactor: 0.5, }); expect(result?.flags).toEqual(['Partial Freeleech']); }); it('returns null when transformResult throws', () => { const service = new ProwlarrService('http://prowlarr', 'key'); const result = (service as any).transformResult({ guid: 'g6', indexer: 'IndexerD', title: null, size: 100, publishDate: '2024-01-01T00:00:00.000Z', downloadUrl: 'https://example.com/book.torrent', }); expect(result).toBeNull(); }); it('returns indexers and stats', async () => { clientMock.get .mockResolvedValueOnce({ data: [{ id: 1, name: 'IndexerA' }] }) .mockResolvedValueOnce({ data: { indexers: [] } }); const service = new ProwlarrService('http://prowlarr', 'key'); const indexers = await service.getIndexers(); const stats = await service.getStats(); expect(indexers).toHaveLength(1); expect(stats.indexers).toEqual([]); }); it('returns false when connection test fails', async () => { clientMock.get.mockRejectedValue(new Error('health down')); const service = new ProwlarrService('http://prowlarr', 'key'); const ok = await service.testConnection(); expect(ok).toBe(false); }); it('throws when indexer stats cannot be fetched', async () => { clientMock.get.mockRejectedValue(new Error('no stats')); const service = new ProwlarrService('http://prowlarr', 'key'); await expect(service.getStats()).rejects.toThrow('Failed to get indexer statistics'); }); it('returns a singleton service from configuration', async () => { const originalApiKey = process.env.PROWLARR_API_KEY; delete process.env.PROWLARR_API_KEY; vi.resetModules(); configMock.getMany.mockResolvedValue({ prowlarr_url: 'http://prowlarr', prowlarr_api_key: 'api-key', }); clientMock.get.mockResolvedValue({ data: {} }); const { getProwlarrService } = await import('@/lib/integrations/prowlarr.service'); const serviceA = await getProwlarrService(); const serviceB = await getProwlarrService(); expect(serviceA).toBe(serviceB); if (originalApiKey === undefined) { delete process.env.PROWLARR_API_KEY; } else { process.env.PROWLARR_API_KEY = originalApiKey; } }); it('throws when Prowlarr API key is missing', async () => { const originalApiKey = process.env.PROWLARR_API_KEY; delete process.env.PROWLARR_API_KEY; vi.resetModules(); configMock.getMany.mockResolvedValue({ prowlarr_url: 'http://prowlarr', prowlarr_api_key: '', }); const { getProwlarrService } = await import('@/lib/integrations/prowlarr.service'); await expect(getProwlarrService()).rejects.toThrow('Prowlarr API key not configured'); if (originalApiKey === undefined) { delete process.env.PROWLARR_API_KEY; } else { process.env.PROWLARR_API_KEY = originalApiKey; } }); it('returns service even when connection test fails', async () => { const originalApiKey = process.env.PROWLARR_API_KEY; delete process.env.PROWLARR_API_KEY; vi.resetModules(); configMock.getMany.mockResolvedValue({ prowlarr_url: 'http://prowlarr', prowlarr_api_key: 'api-key', }); clientMock.get.mockRejectedValue(new Error('health down')); const { getProwlarrService } = await import('@/lib/integrations/prowlarr.service'); const service = await getProwlarrService(); expect(service).toBeDefined(); if (originalApiKey === undefined) { delete process.env.PROWLARR_API_KEY; } else { process.env.PROWLARR_API_KEY = originalApiKey; } }); });