Files
ReadMeABook/tests/services/download-client-manager.service.test.ts
T
kikootwo 4b90b35748 Add Transmission/NZBGet and per-client paths and much more
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
2026-02-09 19:45:43 -05:00

579 lines
19 KiB
TypeScript

/**
* 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 credential migration service - passwords in tests are plaintext
vi.mock('@/lib/services/credential-migration.service', () => ({
isEncryptedFormat: () => false, // Test passwords are plaintext
}));
// Mock encryption service
vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => ({
encrypt: (value: string) => `enc-${value}`,
decrypt: (value: string) => value.replace('enc-', ''),
}),
}));
// Mock all 4 download client services - use vi.hoisted to ensure they're available at mock time
const { qbtServiceMock, sabServiceMock, transmissionServiceMock, nzbgetServiceMock } = vi.hoisted(() => ({
qbtServiceMock: {
testConnection: vi.fn(),
},
sabServiceMock: {
testConnection: vi.fn(),
},
transmissionServiceMock: {
testConnection: vi.fn(),
},
nzbgetServiceMock: {
testConnection: 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 {
testConnection = sabServiceMock.testConnection;
},
}));
vi.mock('@/lib/integrations/transmission.service', () => ({
TransmissionService: class MockTransmissionService {
testConnection = transmissionServiceMock.testConnection;
},
}));
vi.mock('@/lib/integrations/nzbget.service', () => ({
NZBGetService: class MockNZBGetService {
testConnection = nzbgetServiceMock.testConnection;
},
}));
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 Transmission client for torrent protocol', async () => {
const clients = [
{
id: 'client-1',
type: 'transmission',
name: 'Transmission',
enabled: true,
url: 'http://localhost:9091',
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 NZBGet client for usenet protocol', async () => {
const clients = [
{
id: 'client-1',
type: 'nzbget',
name: 'NZBGet',
enabled: true,
url: 'http://localhost:6789',
username: 'nzbget',
password: 'tegbzn6789',
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({ success: true, message: 'Connected' });
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.testConnection.mockResolvedValue({ success: true, version: '3.5.0', message: 'Connected' });
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');
});
it('successfully tests NZBGet connection', async () => {
nzbgetServiceMock.testConnection.mockResolvedValue({ success: true, version: '24.2', message: 'Connected' });
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
const manager = getDownloadClientManager(configMock as any);
const config = {
id: 'client-1',
type: 'nzbget' as const,
name: 'NZBGet',
enabled: true,
url: 'http://localhost:6789',
username: 'nzbget',
password: 'tegbzn6789',
disableSSLVerify: false,
remotePathMappingEnabled: false,
category: 'readmeabook',
};
const result = await manager.testConnection(config);
expect(result.success).toBe(true);
expect(result.message).toBe('Successfully connected to NZBGet (v24.2)');
});
it('successfully tests Transmission connection', async () => {
transmissionServiceMock.testConnection.mockResolvedValue({ success: true, version: '4.0.5', message: 'Connected' });
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
const manager = getDownloadClientManager(configMock as any);
const config = {
id: 'client-1',
type: 'transmission' as const,
name: 'Transmission',
enabled: true,
url: 'http://localhost:9091',
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 Transmission (v4.0.5)');
});
});
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);
});
});
});