Some checks failed
Build and Push Docker Image / build (push) Successful in 31s
Docs Check / Markdown lint (push) Successful in 31s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m12s
CI / Tests & coverage (push) Failing after 1m39s
CI / Security audit (push) Successful in 1m49s
Docs Check / Mermaid diagram parse check (push) Successful in 1m56s
- Add abstract DownloadClient base class with standardized interface - Refactor QBittorrentClient to extend DownloadClient with Sync API support - Create SABnzbdClient implementing DownloadClient interface - Add TransmissionClient as proof-of-concept implementation - Implement DownloadClientRegistry for factory pattern and client management - Refactor poller.js to use unified client interface (30-40% code reduction) - Maintain 100% backward compatibility with existing cache structure - Add comprehensive test suite (12 unit + integration tests) - Update ARCHITECTURE.md with detailed PDCA documentation - Create ADDING-A-DOWNLOAD-CLIENT.md guide for future client additions Features: - Client-agnostic polling with error isolation - Consistent data normalization across all clients - Easy extensibility for new download client types - Zero breaking changes to existing functionality - Parallel execution with unified timing and logging
222 lines
6.0 KiB
JavaScript
222 lines
6.0 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
const QBittorrentClient = require('../../../server/clients/QBittorrentClient');
|
|
const axios = require('axios');
|
|
|
|
// Mock axios
|
|
jest.mock('axios');
|
|
jest.mock('../../../server/utils/logger', () => ({
|
|
logToFile: jest.fn()
|
|
}));
|
|
|
|
describe('QBittorrentClient', () => {
|
|
let client;
|
|
let mockConfig;
|
|
|
|
beforeEach(() => {
|
|
mockConfig = {
|
|
id: 'test-qb',
|
|
name: 'Test qBittorrent',
|
|
url: 'http://localhost:8080',
|
|
username: 'admin',
|
|
password: 'adminadmin'
|
|
};
|
|
|
|
client = new QBittorrentClient(mockConfig);
|
|
|
|
// Clear all mocks
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('Constructor', () => {
|
|
it('should initialize with correct properties', () => {
|
|
expect(client.getClientType()).toBe('qbittorrent');
|
|
expect(client.getInstanceId()).toBe('test-qb');
|
|
expect(client.name).toBe('Test qBittorrent');
|
|
expect(client.url).toBe('http://localhost:8080');
|
|
expect(client.authCookie).toBeNull();
|
|
expect(client.lastRid).toBe(0);
|
|
expect(client.torrentMap).toBeInstanceOf(Map);
|
|
expect(client.fallbackThisCycle).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Authentication', () => {
|
|
it('should login successfully with valid credentials', async () => {
|
|
const mockResponse = {
|
|
headers: {
|
|
'set-cookie': ['SID=test-cookie']
|
|
}
|
|
};
|
|
|
|
axios.post.mockResolvedValue(mockResponse);
|
|
|
|
const result = await client.login();
|
|
|
|
expect(result).toBe(true);
|
|
expect(client.authCookie).toBe('SID=test-cookie');
|
|
expect(axios.post).toHaveBeenCalledWith(
|
|
'http://localhost:8080/api/v2/auth/login',
|
|
'username=admin&password=adminadmin',
|
|
expect.objectContaining({
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle login failure', async () => {
|
|
const mockResponse = {
|
|
headers: {}
|
|
};
|
|
|
|
axios.post.mockResolvedValue(mockResponse);
|
|
|
|
const result = await client.login();
|
|
|
|
expect(result).toBe(false);
|
|
expect(client.authCookie).toBeNull();
|
|
});
|
|
|
|
it('should handle login error', async () => {
|
|
axios.post.mockRejectedValue(new Error('Network error'));
|
|
|
|
const result = await client.login();
|
|
|
|
expect(result).toBe(false);
|
|
expect(client.authCookie).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Connection Test', () => {
|
|
it('should test connection successfully', async () => {
|
|
// Mock login success
|
|
client.login = jest.fn().mockResolvedValue(true);
|
|
|
|
// Mock version request
|
|
const mockResponse = { data: 'v4.3.5' };
|
|
client.makeRequest = jest.fn().mockResolvedValue(mockResponse);
|
|
|
|
const result = await client.testConnection();
|
|
|
|
expect(result).toBe(true);
|
|
expect(client.makeRequest).toHaveBeenCalledWith('/api/v2/app/version');
|
|
});
|
|
|
|
it('should handle connection test failure', async () => {
|
|
client.login = jest.fn().mockRejectedValue(new Error('Auth failed'));
|
|
|
|
const result = await client.testConnection();
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Download Normalization', () => {
|
|
it('should normalize torrent data correctly', () => {
|
|
const torrent = {
|
|
hash: 'abc123',
|
|
name: 'Test Torrent',
|
|
state: 'downloading',
|
|
progress: 0.75,
|
|
size: 1000000000,
|
|
completed: 750000000,
|
|
dlspeed: 1048576,
|
|
eta: 3600,
|
|
category: 'movies',
|
|
tags: 'movie,hd',
|
|
content_path: '/downloads/test',
|
|
added_on: 1640995200
|
|
};
|
|
|
|
const normalized = client.normalizeDownload(torrent);
|
|
|
|
expect(normalized).toEqual({
|
|
id: 'abc123',
|
|
title: 'Test Torrent',
|
|
type: 'torrent',
|
|
client: 'qbittorrent',
|
|
instanceId: 'test-qb',
|
|
instanceName: 'Test qBittorrent',
|
|
status: 'Downloading',
|
|
progress: 75,
|
|
size: 1000000000,
|
|
downloaded: 750000000,
|
|
speed: 1048576,
|
|
eta: 3600,
|
|
category: 'movies',
|
|
tags: ['movie', 'hd'],
|
|
savePath: '/downloads/test',
|
|
addedOn: '2022-01-01T00:00:00.000Z',
|
|
raw: torrent
|
|
});
|
|
});
|
|
|
|
it('should handle unknown torrent states', () => {
|
|
const torrent = {
|
|
hash: 'abc123',
|
|
name: 'Test Torrent',
|
|
state: 'unknown_state',
|
|
progress: 0.5,
|
|
size: 1000000,
|
|
completed: 500000,
|
|
dlspeed: 0,
|
|
eta: -1
|
|
};
|
|
|
|
const normalized = client.normalizeDownload(torrent);
|
|
|
|
expect(normalized.status).toBe('unknown_state');
|
|
expect(normalized.eta).toBeNull();
|
|
});
|
|
|
|
it('should handle missing completed field', () => {
|
|
const torrent = {
|
|
hash: 'abc123',
|
|
name: 'Test Torrent',
|
|
state: 'downloading',
|
|
progress: 0.5,
|
|
size: 1000000,
|
|
dlspeed: 0
|
|
};
|
|
|
|
const normalized = client.normalizeDownload(torrent);
|
|
|
|
expect(normalized.downloaded).toBe(500000);
|
|
});
|
|
});
|
|
|
|
describe('Fallback Flag Management', () => {
|
|
it('should reset fallback flag', () => {
|
|
client.fallbackThisCycle = true;
|
|
client.resetFallbackFlag();
|
|
expect(client.fallbackThisCycle).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('should handle makeRequest authentication failure', async () => {
|
|
client.authCookie = 'invalid-cookie';
|
|
|
|
// First call fails with 403
|
|
const authError = {
|
|
response: { status: 403 }
|
|
};
|
|
|
|
// Second login attempt succeeds
|
|
client.login = jest.fn()
|
|
.mockResolvedValueOnce(false)
|
|
.mockResolvedValueOnce(true);
|
|
|
|
// Retry request succeeds
|
|
const successResponse = { data: 'success' };
|
|
axios.get = jest.fn()
|
|
.mockRejectedValueOnce(authError)
|
|
.mockResolvedValueOnce(successResponse);
|
|
|
|
const result = await client.makeRequest('/test');
|
|
|
|
expect(result).toEqual(successResponse);
|
|
expect(client.login).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
});
|