mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
2cda6decbe
Implements support for configuring both qBittorrent and SABnzbd simultaneously, including migration from legacy config, protocol-aware routing, and protocol filtering. Adds new CRUD API routes for download clients, new UI management components, and updates setup and settings flows to use the new multi-client architecture. Updates documentation to describe the new structure and usage.
446 lines
14 KiB
TypeScript
446 lines
14 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 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);
|
|
});
|
|
});
|
|
});
|