mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
4b90b35748
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.
579 lines
19 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|