/** * Component: Download Client Manager Service Tests * Documentation: documentation/phase3/download-clients.md */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPrismaMock } from '../helpers/prisma'; const prismaMock = createPrismaMock(); const configMock = vi.hoisted(() => ({ get: vi.fn(), setMany: vi.fn(), })); vi.mock('@/lib/db', () => ({ prisma: prismaMock, })); vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => configMock, })); // Mock qBittorrent and SABnzbd services - use vi.hoisted to ensure they're available at mock time const { qbtServiceMock, sabServiceMock } = vi.hoisted(() => ({ qbtServiceMock: { testConnection: vi.fn(), }, sabServiceMock: { getVersion: vi.fn(), }, })); // Use class syntax for proper constructor mocking vi.mock('@/lib/integrations/qbittorrent.service', () => ({ QBittorrentService: class MockQBittorrentService { testConnection = qbtServiceMock.testConnection; }, })); vi.mock('@/lib/integrations/sabnzbd.service', () => ({ SABnzbdService: class MockSABnzbdService { getVersion = sabServiceMock.getVersion; }, })); describe('DownloadClientManager', () => { beforeEach(async () => { vi.clearAllMocks(); // Reset singleton using dynamic import const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); invalidateDownloadClientManager(); }); describe('getAllClients', () => { it('returns parsed clients from config', async () => { const clients = [ { id: 'client-1', type: 'qbittorrent', name: 'qBittorrent', enabled: true, url: 'http://localhost:8080', username: 'admin', password: 'password', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }, ]; configMock.get.mockResolvedValue(JSON.stringify(clients)); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); const result = await manager.getAllClients(); expect(result).toEqual(clients); expect(configMock.get).toHaveBeenCalledWith('download_clients'); }); it('returns empty array when no clients configured', async () => { configMock.get.mockResolvedValue(null); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); const result = await manager.getAllClients(); expect(result).toEqual([]); }); it('caches clients for subsequent calls', async () => { const clients = [ { id: 'client-1', type: 'qbittorrent', name: 'qBittorrent', enabled: true, url: 'http://localhost:8080', username: 'admin', password: 'password', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }, ]; configMock.get.mockResolvedValue(JSON.stringify(clients)); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); await manager.getAllClients(); await manager.getAllClients(); expect(configMock.get).toHaveBeenCalledTimes(1); }); }); describe('getClientForProtocol', () => { it('returns qBittorrent client for torrent protocol', async () => { const clients = [ { id: 'client-1', type: 'qbittorrent', name: 'qBittorrent', enabled: true, url: 'http://localhost:8080', username: 'admin', password: 'password', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }, ]; configMock.get.mockResolvedValue(JSON.stringify(clients)); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); const result = await manager.getClientForProtocol('torrent'); expect(result).toEqual(clients[0]); }); it('returns SABnzbd client for usenet protocol', async () => { const clients = [ { id: 'client-1', type: 'sabnzbd', name: 'SABnzbd', enabled: true, url: 'http://localhost:8081', password: 'apikey', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }, ]; configMock.get.mockResolvedValue(JSON.stringify(clients)); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); const result = await manager.getClientForProtocol('usenet'); expect(result).toEqual(clients[0]); }); it('returns null when no client configured for protocol', async () => { const clients = [ { id: 'client-1', type: 'qbittorrent', name: 'qBittorrent', enabled: true, url: 'http://localhost:8080', username: 'admin', password: 'password', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }, ]; configMock.get.mockResolvedValue(JSON.stringify(clients)); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); const result = await manager.getClientForProtocol('usenet'); expect(result).toBeNull(); }); it('skips disabled clients', async () => { const clients = [ { id: 'client-1', type: 'qbittorrent', name: 'qBittorrent', enabled: false, // Disabled url: 'http://localhost:8080', username: 'admin', password: 'password', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }, ]; configMock.get.mockResolvedValue(JSON.stringify(clients)); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); const result = await manager.getClientForProtocol('torrent'); expect(result).toBeNull(); }); }); describe('hasClientForProtocol', () => { it('returns true when client is configured', async () => { const clients = [ { id: 'client-1', type: 'qbittorrent', name: 'qBittorrent', enabled: true, url: 'http://localhost:8080', username: 'admin', password: 'password', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }, ]; configMock.get.mockResolvedValue(JSON.stringify(clients)); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); const result = await manager.hasClientForProtocol('torrent'); expect(result).toBe(true); }); it('returns false when client is not configured', async () => { const clients = [ { id: 'client-1', type: 'qbittorrent', name: 'qBittorrent', enabled: true, url: 'http://localhost:8080', username: 'admin', password: 'password', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }, ]; configMock.get.mockResolvedValue(JSON.stringify(clients)); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); const result = await manager.hasClientForProtocol('usenet'); expect(result).toBe(false); }); }); describe('testConnection', () => { it('successfully tests qBittorrent connection', async () => { qbtServiceMock.testConnection.mockResolvedValue(undefined); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); const config = { id: 'client-1', type: 'qbittorrent' as const, name: 'qBittorrent', enabled: true, url: 'http://localhost:8080', username: 'admin', password: 'password', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }; const result = await manager.testConnection(config); expect(result.success).toBe(true); expect(result.message).toBe('Successfully connected to qBittorrent'); }); it('successfully tests SABnzbd connection', async () => { sabServiceMock.getVersion.mockResolvedValue('3.5.0'); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); const config = { id: 'client-1', type: 'sabnzbd' as const, name: 'SABnzbd', enabled: true, url: 'http://localhost:8081', password: 'apikey', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }; const result = await manager.testConnection(config); expect(result.success).toBe(true); expect(result.message).toBe('Successfully connected to SABnzbd (v3.5.0)'); }); it('returns error on connection failure', async () => { qbtServiceMock.testConnection.mockRejectedValue(new Error('Connection refused')); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); const config = { id: 'client-1', type: 'qbittorrent' as const, name: 'qBittorrent', enabled: true, url: 'http://localhost:8080', username: 'admin', password: 'password', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }; const result = await manager.testConnection(config); expect(result.success).toBe(false); expect(result.message).toBe('Connection refused'); }); }); describe('migration', () => { it('migrates legacy single-client config to array format', async () => { // First call returns null for download_clients (new format doesn't exist) // Then return legacy values for migration configMock.get .mockResolvedValueOnce(null) // download_clients .mockResolvedValueOnce('qbittorrent') // download_client_type .mockResolvedValueOnce('http://localhost:8080') // download_client_url .mockResolvedValueOnce('admin') // download_client_username .mockResolvedValueOnce('password') // download_client_password .mockResolvedValueOnce('false') // download_client_disable_ssl_verify .mockResolvedValueOnce('false') // download_client_remote_path_mapping_enabled .mockResolvedValueOnce(null) // download_client_remote_path .mockResolvedValueOnce(null) // download_client_local_path .mockResolvedValueOnce(null); // sabnzbd_category const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); const result = await manager.getAllClients(); expect(result).toHaveLength(1); expect(result[0].type).toBe('qbittorrent'); expect(result[0].name).toBe('qBittorrent'); expect(result[0].enabled).toBe(true); expect(result[0].url).toBe('http://localhost:8080'); expect(result[0].username).toBe('admin'); expect(result[0].password).toBe('password'); // Should have saved the migrated config expect(configMock.setMany).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ key: 'download_clients', value: expect.stringContaining('qbittorrent'), }), ]) ); }); it('does not migrate when legacy config is incomplete', async () => { configMock.get .mockResolvedValueOnce(null) // download_clients .mockResolvedValueOnce(null) // download_client_type (missing) .mockResolvedValueOnce(null) // download_client_url (missing) .mockResolvedValueOnce(null); // download_client_password (missing) const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); const result = await manager.getAllClients(); expect(result).toEqual([]); expect(configMock.setMany).not.toHaveBeenCalled(); }); }); describe('invalidate', () => { it('clears cache on invalidation', async () => { const clients = [ { id: 'client-1', type: 'qbittorrent', name: 'qBittorrent', enabled: true, url: 'http://localhost:8080', username: 'admin', password: 'password', disableSSLVerify: false, remotePathMappingEnabled: false, category: 'readmeabook', }, ]; configMock.get.mockResolvedValue(JSON.stringify(clients)); const { getDownloadClientManager, invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); await manager.getAllClients(); // First call - caches invalidateDownloadClientManager(); // Invalidate cache await manager.getAllClients(); // Second call - should fetch again expect(configMock.get).toHaveBeenCalledTimes(2); }); }); });