mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +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:
@@ -6,153 +6,717 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DownloadClientStep } from '@/app/setup/steps/DownloadClientStep';
|
||||
|
||||
interface DownloadClient {
|
||||
id: string;
|
||||
type: 'qbittorrent' | 'sabnzbd';
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
username?: string;
|
||||
password: string;
|
||||
disableSSLVerify: boolean;
|
||||
remotePathMappingEnabled: boolean;
|
||||
remotePath?: string;
|
||||
localPath?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
const DownloadClientHarness = ({
|
||||
onNext,
|
||||
onBack,
|
||||
initialState,
|
||||
initialClients = [],
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
initialState?: Partial<React.ComponentProps<typeof DownloadClientStep>>;
|
||||
initialClients?: DownloadClient[];
|
||||
}) => {
|
||||
const [state, setState] = useState({
|
||||
downloadClient: 'qbittorrent' as const,
|
||||
downloadClientUrl: 'https://qbittorrent.local',
|
||||
downloadClientUsername: 'admin',
|
||||
downloadClientPassword: 'secret',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
remotePath: '',
|
||||
localPath: '',
|
||||
...initialState,
|
||||
});
|
||||
const [downloadClients, setDownloadClients] = useState<DownloadClient[]>(initialClients);
|
||||
|
||||
return (
|
||||
<DownloadClientStep
|
||||
{...state}
|
||||
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
|
||||
downloadClients={downloadClients}
|
||||
onUpdate={(field, value) => {
|
||||
if (field === 'downloadClients') {
|
||||
setDownloadClients(value);
|
||||
}
|
||||
}}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to create a mock client
|
||||
const createMockClient = (overrides: Partial<DownloadClient> = {}): DownloadClient => ({
|
||||
id: 'test-client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'secret',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('DownloadClientStep', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('tests connection and enables navigation after success', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, version: '1.2.3' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onNext = vi.fn();
|
||||
describe('Initial State', () => {
|
||||
it('shows empty state when no clients configured', () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object));
|
||||
expect(screen.getByText('No download clients configured yet')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add at least one client to start downloading audiobooks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Connected successfully!/)).toBeInTheDocument();
|
||||
it('shows Add qBittorrent and Add SABnzbd buttons', () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows remote path fields and toggles SSL verify', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
const sslToggle = screen.getByLabelText('Disable SSL Certificate Verification');
|
||||
fireEvent.click(sslToggle);
|
||||
expect(sslToggle).toBeChecked();
|
||||
|
||||
const remoteToggle = screen.getByLabelText('Enable Remote Path Mapping');
|
||||
fireEvent.click(remoteToggle);
|
||||
|
||||
expect(screen.getByPlaceholderText('/remote/mnt/d/done')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('/downloads')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to SABnzbd and shows API key field', async () => {
|
||||
render(
|
||||
<DownloadClientHarness
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{ downloadClient: 'qbittorrent' }}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /SABnzbd/ }));
|
||||
|
||||
expect(screen.getByText('API Key')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Username')).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks next when connection has not been tested', async () => {
|
||||
const onNext = vi.fn();
|
||||
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(screen.getByText('Please test the connection before proceeding')).toBeInTheDocument();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error when the connection test fails', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Bad credentials' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object));
|
||||
expect(screen.getByRole('button', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Add SABnzbd/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Bad credentials/)).toBeInTheDocument();
|
||||
it('displays configured clients when provided', () => {
|
||||
const mockClient = createMockClient({ name: 'My qBittorrent' });
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
expect(screen.getByText('My qBittorrent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No download clients configured yet')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables test connection when SABnzbd fields are incomplete', async () => {
|
||||
render(
|
||||
<DownloadClientHarness
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{
|
||||
downloadClient: 'sabnzbd',
|
||||
downloadClientUrl: '',
|
||||
downloadClientPassword: '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
describe('Adding a qBittorrent Client', () => {
|
||||
it('opens modal when clicking Add qBittorrent', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
const testButton = screen.getByRole('button', { name: 'Test Connection' });
|
||||
expect(testButton).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows correct form fields for qBittorrent', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// qBittorrent should show Name, URL, Username, Password
|
||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('URL')).toBeInTheDocument();
|
||||
expect(screen.getByText('Username')).toBeInTheDocument();
|
||||
expect(screen.getByText('Password')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates required fields before testing connection', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click Test Connection without filling required fields
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
// Should show validation errors
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/URL is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// fetch should not have been called
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tests connection and shows success message', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'Connected to qBittorrent v4.5.0' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill in required fields
|
||||
const urlInput = screen.getByPlaceholderText('http://localhost:8080');
|
||||
const usernameInput = screen.getByPlaceholderText('admin');
|
||||
const passwordInput = screen.getByPlaceholderText('Password');
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: 'http://localhost:8080' } });
|
||||
fireEvent.change(usernameInput, { target: { value: 'admin' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'secret' } });
|
||||
|
||||
// Test connection
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Connected to qBittorrent v4.5.0/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error message when connection test fails', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Invalid credentials' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill in required fields
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://localhost:8080' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'wrong' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Invalid credentials/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('enables save button only after successful connection test', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'Connected successfully!' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Add Client button should be disabled initially
|
||||
const addButton = screen.getByRole('button', { name: /Add Client/i });
|
||||
expect(addButton).toBeDisabled();
|
||||
|
||||
// Fill and test
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://localhost:8080' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Connected successfully!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Now Add Client should be enabled
|
||||
expect(addButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('adds client to list after saving', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'Connected successfully!' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill and test
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://localhost:8080' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Connected successfully!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Save the client
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Client/i }));
|
||||
|
||||
// Modal should close and client should appear in list
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Client should be in the configured clients list
|
||||
expect(screen.getByText('Configured Clients')).toBeInTheDocument();
|
||||
// The client name should be visible in the configured clients section
|
||||
const configuredSection = screen.getByText('Configured Clients').parentElement;
|
||||
expect(configuredSection).toBeInTheDocument();
|
||||
// There should be edit/delete buttons for the configured client
|
||||
expect(screen.getByTitle('Edit client')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Delete client')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides SSL toggle when using http URLs', async () => {
|
||||
render(
|
||||
<DownloadClientHarness
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{ downloadClientUrl: 'http://qbittorrent.local' }}
|
||||
/>
|
||||
);
|
||||
describe('Adding a SABnzbd Client', () => {
|
||||
it('opens modal when clicking Add SABnzbd', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByLabelText('Disable SSL Certificate Verification')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add SABnzbd/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add SABnzbd/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows API Key field instead of Username for SABnzbd', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add SABnzbd/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add SABnzbd/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// SABnzbd should show API Key, not Username
|
||||
expect(screen.getByText('API Key')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Username')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates API key is required for SABnzbd', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add SABnzbd/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add SABnzbd/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill URL but not API key
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8081'), {
|
||||
target: { value: 'http://localhost:8081' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/API key is required/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSL Verification Toggle', () => {
|
||||
it('shows SSL toggle only for HTTPS URLs', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// SSL toggle should not be visible for HTTP
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://localhost:8080' },
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/Disable SSL certificate verification/i)).not.toBeInTheDocument();
|
||||
|
||||
// Change to HTTPS - SSL toggle should appear
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'https://localhost:8080' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Disable SSL certificate verification/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remote Path Mapping', () => {
|
||||
it('shows remote path fields when enabled', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Remote path fields should not be visible initially
|
||||
expect(screen.queryByText(/Remote Path \(qBittorrent\)/i)).not.toBeInTheDocument();
|
||||
|
||||
// Enable remote path mapping
|
||||
const toggle = screen.getByLabelText(/Enable Remote Path Mapping/i);
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Now remote path fields should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Remote Path \(qBittorrent\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Local Path \(ReadMeABook\)/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates remote path fields when enabled', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'Connected!' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill required fields
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://localhost:8080' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } });
|
||||
|
||||
// Enable remote path mapping but don't fill paths
|
||||
fireEvent.click(screen.getByLabelText(/Enable Remote Path Mapping/i));
|
||||
|
||||
// Try to test connection
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Remote path is required/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('blocks Next when no enabled client is configured', () => {
|
||||
const onNext = vi.fn();
|
||||
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(screen.getByText(/Please add at least one download client before proceeding/i)).toBeInTheDocument();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows Next when at least one enabled client exists', () => {
|
||||
const onNext = vi.fn();
|
||||
const mockClient = createMockClient();
|
||||
|
||||
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks Next when client exists but is disabled', () => {
|
||||
const onNext = vi.fn();
|
||||
const mockClient = createMockClient({ enabled: false });
|
||||
|
||||
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(screen.getByText(/Please add at least one download client before proceeding/i)).toBeInTheDocument();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onBack when Back button is clicked', () => {
|
||||
const onBack = vi.fn();
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={onBack} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client Type Restrictions', () => {
|
||||
it('shows "Already configured" when qBittorrent is already added', () => {
|
||||
const mockClient = createMockClient({ type: 'qbittorrent' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// "Already configured" text should appear for qBittorrent
|
||||
expect(screen.getByText('Already configured')).toBeInTheDocument();
|
||||
|
||||
// Add qBittorrent button should not exist
|
||||
expect(screen.queryByRole('button', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
|
||||
|
||||
// SABnzbd should still have Add button
|
||||
expect(screen.getByRole('button', { name: /Add SABnzbd/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Already configured" when SABnzbd is already added', () => {
|
||||
const mockClient = createMockClient({ type: 'sabnzbd', name: 'My SABnzbd' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// "Already configured" text should appear for SABnzbd
|
||||
expect(screen.getByText('Already configured')).toBeInTheDocument();
|
||||
|
||||
// Add SABnzbd button should not exist
|
||||
expect(screen.queryByRole('button', { name: /Add SABnzbd/i })).not.toBeInTheDocument();
|
||||
|
||||
// qBittorrent should still have Add button
|
||||
expect(screen.getByRole('button', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client Card Actions', () => {
|
||||
it('opens edit modal when edit button is clicked', async () => {
|
||||
const mockClient = createMockClient({ name: 'My qBittorrent' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// Find and click edit button
|
||||
const editButton = screen.getByTitle('Edit client');
|
||||
fireEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Edit qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows delete confirmation when delete button is clicked', async () => {
|
||||
const mockClient = createMockClient({ name: 'My qBittorrent' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// Find and click delete button
|
||||
const deleteButton = screen.getByTitle('Delete client');
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete Download Client/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Are you sure you want to delete/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('removes client when delete is confirmed', async () => {
|
||||
const mockClient = createMockClient({ name: 'My qBittorrent' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// Click delete button
|
||||
fireEvent.click(screen.getByTitle('Delete client'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete Download Client/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Confirm deletion
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
// Client should be removed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('My qBittorrent')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No download clients configured yet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels delete when cancel is clicked', async () => {
|
||||
const mockClient = createMockClient({ name: 'My qBittorrent' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// Click delete button
|
||||
fireEvent.click(screen.getByTitle('Delete client'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete Download Client/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Cancel deletion
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
// Dialog should close, client should still be there
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Delete Download Client/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('My qBittorrent')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Clients', () => {
|
||||
it('allows configuring both qBittorrent and SABnzbd', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'Connected!' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
// Add qBittorrent
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://localhost:8080' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Connected!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Client/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Now add SABnzbd
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add SABnzbd/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add SABnzbd/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8081'), {
|
||||
target: { value: 'http://localhost:8081' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/API Key from SABnzbd/i), {
|
||||
target: { value: 'my-api-key' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
// Find the success message in the modal
|
||||
const successMessages = screen.getAllByText(/Connected!/i);
|
||||
expect(successMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Client/i }));
|
||||
|
||||
// Both clients should be in the list - check for edit buttons (2 of them)
|
||||
await waitFor(() => {
|
||||
const editButtons = screen.getAllByTitle('Edit client');
|
||||
expect(editButtons).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Both "Already configured" messages should appear
|
||||
const alreadyConfiguredMessages = screen.getAllByText('Already configured');
|
||||
expect(alreadyConfiguredMessages).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal Behavior', () => {
|
||||
it('closes modal when Cancel is clicked', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes modal when clicking the X button', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the close button (X icon in modal header)
|
||||
const modal = screen.getByRole('heading', { name: /Add qBittorrent/i }).closest('[class*="relative"]');
|
||||
const closeButton = within(modal!).getAllByRole('button')[0]; // First button in modal header area
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets form state when reopening modal', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Connection failed' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
// Open, fill, and trigger error
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://bad-url' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'user' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'pass' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Connection failed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Close modal
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reopen - error should be cleared
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Error message should not be present
|
||||
expect(screen.queryByText(/Connection failed/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,6 @@ describe('Modal', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
expect(document.body.style.overflow).toBe('unset');
|
||||
expect(document.body.style.overflow).toBe(''); // Cleared to default
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ const jobQueueMock = createJobQueueMock();
|
||||
const qbtMock = vi.hoisted(() => ({ addTorrent: vi.fn() }));
|
||||
const sabMock = vi.hoisted(() => ({ addNZB: vi.fn() }));
|
||||
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getClientForProtocol: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
@@ -21,6 +25,10 @@ vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
@@ -33,16 +41,26 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
getSABnzbdService: () => sabMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/prowlarr.service', () => ({
|
||||
ProwlarrService: {
|
||||
isNZBResult: vi.fn((result: any) => {
|
||||
// Detect NZB by URL pattern or protocol field
|
||||
return result.downloadUrl?.endsWith('.nzb') || result.protocol === 'usenet';
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('processDownloadTorrent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const payload = {
|
||||
const torrentPayload = {
|
||||
requestId: 'req-1',
|
||||
audiobook: { id: 'a1', title: 'Book', author: 'Author' },
|
||||
torrent: {
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'Book - Author',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 10,
|
||||
@@ -50,20 +68,44 @@ describe('processDownloadTorrent', () => {
|
||||
downloadUrl: 'magnet:?xt=urn:btih:abc',
|
||||
guid: 'guid-1',
|
||||
format: 'M4B',
|
||||
protocol: 'torrent',
|
||||
},
|
||||
jobId: 'job-1',
|
||||
};
|
||||
|
||||
it('routes downloads to qBittorrent by default', async () => {
|
||||
configMock.get.mockResolvedValue('qbittorrent');
|
||||
const nzbPayload = {
|
||||
requestId: 'req-2',
|
||||
audiobook: { id: 'a2', title: 'Book2', author: 'Author2' },
|
||||
torrent: {
|
||||
indexer: 'UsenetIndexer',
|
||||
indexerId: 2,
|
||||
title: 'Book2 - Author2',
|
||||
size: 100 * 1024 * 1024,
|
||||
seeders: 0,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'http://indexer.com/download/file.nzb',
|
||||
guid: 'guid-2',
|
||||
format: 'M4B',
|
||||
protocol: 'usenet',
|
||||
},
|
||||
jobId: 'job-2',
|
||||
};
|
||||
|
||||
it('routes torrent downloads to qBittorrent', async () => {
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
enabled: true,
|
||||
});
|
||||
qbtMock.addTorrent.mockResolvedValue('hash-1');
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
|
||||
|
||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||
const result = await processDownloadTorrent(payload);
|
||||
const result = await processDownloadTorrent(torrentPayload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
|
||||
expect(qbtMock.addTorrent).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
@@ -74,25 +116,70 @@ describe('processDownloadTorrent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('routes downloads to SABnzbd when configured', async () => {
|
||||
configMock.get.mockResolvedValue('sabnzbd');
|
||||
it('routes NZB downloads to SABnzbd', async () => {
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
enabled: true,
|
||||
});
|
||||
sabMock.addNZB.mockResolvedValue('nzb-1');
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
|
||||
|
||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||
const result = await processDownloadTorrent(payload);
|
||||
const result = await processDownloadTorrent(nzbPayload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('usenet');
|
||||
expect(sabMock.addNZB).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
'req-2',
|
||||
'dh-2',
|
||||
'nzb-1',
|
||||
'sabnzbd',
|
||||
3
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error when no client configured for protocol', async () => {
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null);
|
||||
|
||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||
|
||||
await expect(processDownloadTorrent(torrentPayload)).rejects.toThrow(
|
||||
'No Torrent (qBittorrent) client configured'
|
||||
);
|
||||
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
|
||||
});
|
||||
|
||||
it('detects protocol from result and routes appropriately', async () => {
|
||||
// Torrent result
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValueOnce({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
enabled: true,
|
||||
});
|
||||
qbtMock.addTorrent.mockResolvedValue('hash-1');
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
|
||||
|
||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||
await processDownloadTorrent(torrentPayload);
|
||||
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
|
||||
|
||||
// NZB result
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValueOnce({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
enabled: true,
|
||||
});
|
||||
sabMock.addNZB.mockResolvedValue('nzb-1');
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
|
||||
|
||||
await processDownloadTorrent(nzbPayload);
|
||||
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('usenet');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user