mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add multi-download-client support and UI management
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.
This commit is contained in:
@@ -25,6 +25,13 @@ const configMock = vi.hoisted(() => ({
|
||||
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,
|
||||
@@ -34,16 +41,27 @@ 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 () => {
|
||||
configMock.get.mockResolvedValue('sabnzbd');
|
||||
// Mock: Only SABnzbd is configured (usenet only)
|
||||
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
|
||||
return protocol === 'usenet';
|
||||
});
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -76,7 +94,10 @@ describe('ProwlarrService', () => {
|
||||
});
|
||||
|
||||
it('throws when search fails', async () => {
|
||||
configMock.get.mockResolvedValue('qbittorrent');
|
||||
// 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');
|
||||
@@ -85,7 +106,10 @@ describe('ProwlarrService', () => {
|
||||
});
|
||||
|
||||
it('filters results for qBittorrent (torrent)', async () => {
|
||||
configMock.get.mockResolvedValue('qbittorrent');
|
||||
// Mock: Only qBittorrent is configured (torrent only)
|
||||
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
|
||||
return protocol === 'torrent';
|
||||
});
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -178,7 +202,10 @@ describe('ProwlarrService', () => {
|
||||
});
|
||||
|
||||
it('applies category, indexer, and seeder filters', async () => {
|
||||
configMock.get.mockResolvedValue('qbittorrent');
|
||||
// Mock: Only qBittorrent is configured (torrent only)
|
||||
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
|
||||
return protocol === 'torrent';
|
||||
});
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -223,9 +250,8 @@ describe('ProwlarrService', () => {
|
||||
});
|
||||
|
||||
it('returns unfiltered results when protocol filtering fails', async () => {
|
||||
configMock.get
|
||||
.mockResolvedValueOnce('qbittorrent')
|
||||
.mockRejectedValueOnce(new Error('config fail'));
|
||||
// Mock: hasClientForProtocol throws an error
|
||||
downloadClientManagerMock.hasClientForProtocol.mockRejectedValue(new Error('config fail'));
|
||||
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
|
||||
@@ -21,6 +21,14 @@ const axiosMock = vi.hoisted(() => ({
|
||||
const parseTorrentMock = vi.hoisted(() => vi.fn());
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getMany: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock for DownloadClientManager
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getClientForProtocol: vi.fn(),
|
||||
getAllClients: vi.fn(),
|
||||
hasClientForProtocol: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
@@ -33,7 +41,12 @@ vi.mock('parse-torrent', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
getConfigService: vi.fn(async () => configServiceMock),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
invalidateDownloadClientManager: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('QBittorrentService', () => {
|
||||
@@ -45,6 +58,10 @@ describe('QBittorrentService', () => {
|
||||
axiosMock.post.mockReset();
|
||||
parseTorrentMock.mockReset();
|
||||
configServiceMock.getMany.mockReset();
|
||||
configServiceMock.get.mockReset();
|
||||
downloadClientManagerMock.getClientForProtocol.mockReset();
|
||||
downloadClientManagerMock.getAllClients.mockReset();
|
||||
downloadClientManagerMock.hasClientForProtocol.mockReset();
|
||||
invalidateQBittorrentService();
|
||||
});
|
||||
|
||||
@@ -586,25 +603,26 @@ describe('QBittorrentService', () => {
|
||||
});
|
||||
|
||||
it('throws when qBittorrent configuration is incomplete', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
download_client_url: null,
|
||||
download_client_username: null,
|
||||
download_client_password: null,
|
||||
download_dir: null,
|
||||
download_client_disable_ssl_verify: 'false',
|
||||
});
|
||||
// Mock: no qBittorrent client configured
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null);
|
||||
|
||||
await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not fully configured');
|
||||
await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not configured');
|
||||
});
|
||||
|
||||
it('returns a cached instance after successful initialization', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
download_client_url: 'http://qb',
|
||||
download_client_username: 'user',
|
||||
download_client_password: 'pass',
|
||||
download_dir: '/downloads',
|
||||
download_client_disable_ssl_verify: 'false',
|
||||
// Mock: qBittorrent client configured
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://qb',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
configServiceMock.get.mockResolvedValue('/downloads');
|
||||
|
||||
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(true);
|
||||
|
||||
@@ -612,19 +630,26 @@ describe('QBittorrentService', () => {
|
||||
const second = await getQBittorrentService();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(configServiceMock.getMany).toHaveBeenCalledTimes(1);
|
||||
// Should only call getClientForProtocol once (cached after first call)
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledTimes(1);
|
||||
|
||||
testConnectionSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('throws when connection test fails during service creation', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
download_client_url: 'http://qb',
|
||||
download_client_username: 'user',
|
||||
download_client_password: 'pass',
|
||||
download_dir: '/downloads',
|
||||
download_client_disable_ssl_verify: 'false',
|
||||
// Mock: qBittorrent client configured
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://qb',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
configServiceMock.get.mockResolvedValue('/downloads');
|
||||
|
||||
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(false);
|
||||
|
||||
|
||||
@@ -18,13 +18,25 @@ const configServiceMock = vi.hoisted(() => ({
|
||||
get: 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: () => configServiceMock,
|
||||
getConfigService: vi.fn(async () => configServiceMock),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
invalidateDownloadClientManager: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('SABnzbdService', () => {
|
||||
@@ -32,6 +44,9 @@ describe('SABnzbdService', () => {
|
||||
vi.clearAllMocks();
|
||||
clientMock.get.mockReset();
|
||||
configServiceMock.get.mockReset();
|
||||
downloadClientManagerMock.getClientForProtocol.mockReset();
|
||||
downloadClientManagerMock.getAllClients.mockReset();
|
||||
downloadClientManagerMock.hasClientForProtocol.mockReset();
|
||||
invalidateSABnzbdService();
|
||||
});
|
||||
|
||||
@@ -456,22 +471,19 @@ describe('SABnzbdService', () => {
|
||||
});
|
||||
|
||||
it('creates a singleton service from config', async () => {
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
switch (key) {
|
||||
case 'download_client_url':
|
||||
return 'http://sab';
|
||||
case 'download_client_password':
|
||||
return 'api-key';
|
||||
case 'sabnzbd_category':
|
||||
return 'books';
|
||||
case 'download_client_disable_ssl_verify':
|
||||
return 'false';
|
||||
case 'download_dir':
|
||||
return '/downloads';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
// Mock: SABnzbd client configured via DownloadClientManager
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'sabnzbd',
|
||||
name: 'SABnzbd',
|
||||
enabled: true,
|
||||
url: 'http://sab',
|
||||
password: 'api-key', // API key stored in password field
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'books',
|
||||
});
|
||||
configServiceMock.get.mockResolvedValue('/downloads');
|
||||
|
||||
const ensureSpy = vi.spyOn(SABnzbdService.prototype, 'ensureCategory').mockResolvedValue();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user