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
305 lines
8.7 KiB
JavaScript
305 lines
8.7 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
const SABnzbdClient = require('../../../server/clients/SABnzbdClient');
|
|
const axios = require('axios');
|
|
|
|
// Mock axios
|
|
jest.mock('axios');
|
|
jest.mock('../../../server/utils/logger', () => ({
|
|
logToFile: jest.fn()
|
|
}));
|
|
|
|
describe('SABnzbdClient', () => {
|
|
let client;
|
|
let mockConfig;
|
|
|
|
beforeEach(() => {
|
|
mockConfig = {
|
|
id: 'test-sab',
|
|
name: 'Test SABnzbd',
|
|
url: 'http://localhost:8080',
|
|
apiKey: 'test-api-key'
|
|
};
|
|
|
|
client = new SABnzbdClient(mockConfig);
|
|
|
|
// Clear all mocks
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('Constructor', () => {
|
|
it('should initialize with correct properties', () => {
|
|
expect(client.getClientType()).toBe('sabnzbd');
|
|
expect(client.getInstanceId()).toBe('test-sab');
|
|
expect(client.name).toBe('Test SABnzbd');
|
|
expect(client.url).toBe('http://localhost:8080');
|
|
expect(client.apiKey).toBe('test-api-key');
|
|
});
|
|
});
|
|
|
|
describe('Connection Test', () => {
|
|
it('should test connection successfully', async () => {
|
|
const mockResponse = {
|
|
data: { version: '3.6.1' }
|
|
};
|
|
|
|
client.makeRequest = jest.fn().mockResolvedValue(mockResponse);
|
|
|
|
const result = await client.testConnection();
|
|
|
|
expect(result).toBe(true);
|
|
expect(client.makeRequest).toHaveBeenCalledWith({ mode: 'version' });
|
|
});
|
|
|
|
it('should handle connection test failure', async () => {
|
|
client.makeRequest = jest.fn().mockRejectedValue(new Error('Connection failed'));
|
|
|
|
const result = await client.testConnection();
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('API Requests', () => {
|
|
it('should make API request with correct parameters', async () => {
|
|
const mockResponse = { data: { result: 'success' } };
|
|
|
|
axios.get.mockResolvedValue(mockResponse);
|
|
|
|
const result = await client.makeRequest({ mode: 'queue', limit: 10 });
|
|
|
|
expect(axios.get).toHaveBeenCalledWith('http://localhost:8080/api', {
|
|
params: {
|
|
output: 'json',
|
|
apikey: 'test-api-key',
|
|
mode: 'queue',
|
|
limit: 10
|
|
}
|
|
});
|
|
|
|
expect(result).toEqual(mockResponse);
|
|
});
|
|
|
|
it('should handle API request errors', async () => {
|
|
const error = new Error('API Error');
|
|
axios.get.mockRejectedValue(error);
|
|
|
|
await expect(client.makeRequest({ mode: 'queue' })).rejects.toThrow('API Error');
|
|
});
|
|
});
|
|
|
|
describe('Download Normalization', () => {
|
|
it('should normalize queue download correctly', () => {
|
|
const slot = {
|
|
nzo_id: 'test123',
|
|
filename: 'Test Movie.mkv',
|
|
status: 'Downloading',
|
|
progress: 75.5,
|
|
mb: 1000,
|
|
mbleft: 245,
|
|
kbpersec: 1024,
|
|
timeleft: '0:15:30',
|
|
cat: 'movies',
|
|
labels: 'movie,hd',
|
|
added: 1640995200
|
|
};
|
|
|
|
const normalized = client.normalizeDownload(slot, 'queue');
|
|
|
|
expect(normalized).toEqual({
|
|
id: 'test123',
|
|
title: 'Test Movie.mkv',
|
|
type: 'usenet',
|
|
client: 'sabnzbd',
|
|
instanceId: 'test-sab',
|
|
instanceName: 'Test SABnzbd',
|
|
status: 'Downloading',
|
|
progress: 76,
|
|
size: 1000 * 1024 * 1024,
|
|
downloaded: 755 * 1024 * 1024,
|
|
speed: 1024 * 1024,
|
|
eta: 930, // 15 minutes 30 seconds
|
|
category: 'movies',
|
|
tags: ['movie', 'hd'],
|
|
savePath: undefined,
|
|
addedOn: '2022-01-01T00:00:00.000Z',
|
|
raw: { ...slot, source: 'queue' }
|
|
});
|
|
});
|
|
|
|
it('should normalize history download correctly', () => {
|
|
const slot = {
|
|
nzo_id: 'test456',
|
|
filename: 'Test Series S01E01.mkv',
|
|
status: 'Completed',
|
|
mb: 500,
|
|
cat: 'tv',
|
|
added: 1640995200
|
|
};
|
|
|
|
const normalized = client.normalizeDownload(slot, 'history');
|
|
|
|
expect(normalized.status).toBe('Completed');
|
|
expect(normalized.progress).toBe(100);
|
|
expect(normalized.downloaded).toBe(500 * 1024 * 1024);
|
|
expect(normalized.speed).toBe(0);
|
|
expect(normalized.eta).toBeNull();
|
|
expect(normalized.raw.source).toBe('history');
|
|
});
|
|
|
|
it('should parse time strings correctly', () => {
|
|
const testCases = [
|
|
{ input: '0:05:30', expected: 330 }, // 5m 30s
|
|
{ input: '15:30', expected: 930 }, // 15m 30s
|
|
{ input: '330', expected: 330 }, // 330 seconds
|
|
{ input: 'unknown', expected: null }, // unknown
|
|
{ input: '0:00', expected: null } // zero
|
|
];
|
|
|
|
testCases.forEach(({ input, expected }) => {
|
|
const slot = {
|
|
nzo_id: 'test',
|
|
filename: 'Test',
|
|
status: 'Downloading',
|
|
progress: 50,
|
|
mb: 1000,
|
|
mbleft: 500,
|
|
timeleft: input
|
|
};
|
|
|
|
const normalized = client.normalizeDownload(slot, 'queue');
|
|
expect(normalized.eta).toBe(expected);
|
|
});
|
|
});
|
|
|
|
it('should extract Sonarr/Radarr info from filename', () => {
|
|
const testCases = [
|
|
{ filename: 'Show Name - S01E02 - Episode Title', expectedType: 'series' },
|
|
{ filename: 'Movie Title (2023) 1080p', expectedType: 'movie' },
|
|
{ filename: 'Random File Name.mkv', expectedType: undefined }
|
|
];
|
|
|
|
testCases.forEach(({ filename, expectedType }) => {
|
|
const slot = {
|
|
nzo_id: 'test',
|
|
filename: filename,
|
|
status: 'Downloading',
|
|
progress: 50,
|
|
mb: 1000,
|
|
mbleft: 500
|
|
};
|
|
|
|
const normalized = client.normalizeDownload(slot, 'queue');
|
|
expect(normalized.arrType).toBe(expectedType);
|
|
});
|
|
});
|
|
|
|
it('should handle size parsing from strings', () => {
|
|
const slot = {
|
|
nzo_id: 'test',
|
|
filename: 'Test',
|
|
status: 'Downloading',
|
|
progress: 50,
|
|
size: '1.5 GB',
|
|
sizeleft: '750 MB'
|
|
};
|
|
|
|
const normalized = client.normalizeDownload(slot, 'queue');
|
|
|
|
expect(normalized.size).toBe(1.5 * 1024 * 1024 * 1024);
|
|
expect(normalized.downloaded).toBe(0.75 * 1024 * 1024 * 1024);
|
|
expect(normalized.progress).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe('Unit Multipliers', () => {
|
|
it('should return correct multipliers for different units', () => {
|
|
expect(client.getUnitMultiplier('b')).toBe(1);
|
|
expect(client.getUnitMultiplier('KB')).toBe(1024);
|
|
expect(client.getUnitMultiplier('mb')).toBe(1024 * 1024);
|
|
expect(client.getUnitMultiplier('GB')).toBe(1024 * 1024 * 1024);
|
|
expect(client.getUnitMultiplier('tb')).toBe(1024 * 1024 * 1024 * 1024);
|
|
expect(client.getUnitMultiplier('unknown')).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('Active Downloads', () => {
|
|
it('should fetch and normalize downloads from queue and history', async () => {
|
|
const mockQueueResponse = {
|
|
data: {
|
|
queue: {
|
|
slots: [
|
|
{ nzo_id: 'queue1', filename: 'Queue Item', status: 'Downloading' }
|
|
]
|
|
}
|
|
}
|
|
};
|
|
|
|
const mockHistoryResponse = {
|
|
data: {
|
|
history: {
|
|
slots: [
|
|
{ nzo_id: 'hist1', filename: 'History Item', status: 'Completed' }
|
|
]
|
|
}
|
|
}
|
|
};
|
|
|
|
client.makeRequest = jest.fn()
|
|
.mockResolvedValueOnce(mockQueueResponse)
|
|
.mockResolvedValueOnce(mockHistoryResponse);
|
|
|
|
const downloads = await client.getActiveDownloads();
|
|
|
|
expect(client.makeRequest).toHaveBeenCalledWith({ mode: 'queue' });
|
|
expect(client.makeRequest).toHaveBeenCalledWith({ mode: 'history', limit: 10 });
|
|
expect(downloads).toHaveLength(2);
|
|
expect(downloads[0].id).toBe('queue1');
|
|
expect(downloads[1].id).toBe('hist1');
|
|
});
|
|
|
|
it('should handle API errors gracefully', async () => {
|
|
client.makeRequest = jest.fn().mockRejectedValue(new Error('API Error'));
|
|
|
|
const downloads = await client.getActiveDownloads();
|
|
|
|
expect(downloads).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('Client Status', () => {
|
|
it('should get client status', async () => {
|
|
const mockResponse = {
|
|
data: {
|
|
queue: {
|
|
status: 'Active',
|
|
speed: 1048576,
|
|
kbpersec: 1024,
|
|
sizeleft: 500000000,
|
|
mbleft: 500
|
|
}
|
|
}
|
|
};
|
|
|
|
client.makeRequest = jest.fn().mockResolvedValue(mockResponse);
|
|
|
|
const status = await client.getClientStatus();
|
|
|
|
expect(status).toEqual({
|
|
status: 'Active',
|
|
speed: 1048576,
|
|
kbpersec: 1024,
|
|
sizeleft: 500000000,
|
|
mbleft: 500
|
|
});
|
|
});
|
|
|
|
it('should handle status request errors', async () => {
|
|
client.makeRequest = jest.fn().mockRejectedValue(new Error('Status error'));
|
|
|
|
const status = await client.getClientStatus();
|
|
|
|
expect(status).toBeNull();
|
|
});
|
|
});
|
|
});
|