6fa9c79a7d
Build and Push Docker Image / build (push) Successful in 59s
Docs Check / Markdown lint (push) Failing after 1m45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m7s
CI / Security audit (push) Successful in 2m33s
Docs Check / Mermaid diagram parse check (push) Successful in 2m55s
CI / Swagger Validation & Coverage (push) Successful in 3m19s
CI / Tests & coverage (push) Successful in 3m29s
- RTorrentClient: guard d.multicall2 returning non-array, per-row try/catch, explicit Number()/String() coercions, _extractArrInfo null-safe - RTorrentClient.getClientStatus: coerce rates through Number.isFinite - SABnzbdClient: history limit now reads SAB_HISTORY_LIMIT env var (default 10) - DownloadClient: added _recordLastError, _clearLastError, getLastError on base - All four clients call _recordLastError on failure, _clearLastError on success - DownloadClientRegistry.getAllClientStatuses: includes lastError in result - GET /api/status/status: exposes downloadClients[] array with per-client lastError - Tests: RTorrentClient null-safety + lastError, SABnzbd history limit + lastError, downloadClients.test expectation updated for new lastError field
362 lines
11 KiB
JavaScript
362 lines
11 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
import SABnzbdClient from '../../../server/clients/SABnzbdClient.js';
|
|
import nock from 'nock';
|
|
import { vi } from 'vitest';
|
|
vi.mock('../../../server/utils/logger', () => ({
|
|
logToFile: vi.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
|
|
vi.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 = vi.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 = vi.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 () => {
|
|
nock('http://localhost:8080')
|
|
.get('/api')
|
|
.query({
|
|
output: 'json',
|
|
apikey: 'test-api-key',
|
|
mode: 'queue',
|
|
limit: 10
|
|
})
|
|
.reply(200, { result: 'success' });
|
|
|
|
const result = await client.makeRequest({ mode: 'queue', limit: 10 });
|
|
|
|
expect(result.data).toEqual({ result: 'success' });
|
|
});
|
|
|
|
it('should handle API request errors', async () => {
|
|
nock('http://localhost:8080')
|
|
.get('/api')
|
|
.query({ output: 'json', apikey: 'test-api-key', mode: 'queue' })
|
|
.replyWithError(new Error('API 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,
|
|
mbleft: 0,
|
|
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: '0.75 GB'
|
|
};
|
|
|
|
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 = vi.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 = vi.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 = vi.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 = vi.fn().mockRejectedValue(new Error('Status error'));
|
|
|
|
const status = await client.getClientStatus();
|
|
|
|
expect(status).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('History limit configuration (Issue #68)', () => {
|
|
const ORIG_ENV = process.env.SAB_HISTORY_LIMIT;
|
|
afterEach(() => {
|
|
if (ORIG_ENV === undefined) delete process.env.SAB_HISTORY_LIMIT;
|
|
else process.env.SAB_HISTORY_LIMIT = ORIG_ENV;
|
|
});
|
|
|
|
it('defaults historyLimit to 10 when SAB_HISTORY_LIMIT is unset', () => {
|
|
delete process.env.SAB_HISTORY_LIMIT;
|
|
const c = new SABnzbdClient(mockConfig);
|
|
expect(c.historyLimit).toBe(10);
|
|
});
|
|
|
|
it('honors SAB_HISTORY_LIMIT when set to a valid integer', () => {
|
|
process.env.SAB_HISTORY_LIMIT = '25';
|
|
const c = new SABnzbdClient(mockConfig);
|
|
expect(c.historyLimit).toBe(25);
|
|
});
|
|
|
|
it('falls back to default on invalid SAB_HISTORY_LIMIT', () => {
|
|
process.env.SAB_HISTORY_LIMIT = 'not-a-number';
|
|
const c = new SABnzbdClient(mockConfig);
|
|
expect(c.historyLimit).toBe(10);
|
|
});
|
|
|
|
it('passes historyLimit through to the history API call', async () => {
|
|
process.env.SAB_HISTORY_LIMIT = '42';
|
|
const c = new SABnzbdClient(mockConfig);
|
|
const makeRequest = vi.fn()
|
|
.mockResolvedValueOnce({ data: { queue: { slots: [], kbpersec: 0 } } })
|
|
.mockResolvedValueOnce({ data: { history: { slots: [] } } });
|
|
c.makeRequest = makeRequest;
|
|
await c.getActiveDownloads();
|
|
expect(makeRequest).toHaveBeenCalledWith({ mode: 'history', limit: 42 });
|
|
});
|
|
});
|
|
|
|
describe('lastError tracking (Issue #68)', () => {
|
|
it('records lastError when getActiveDownloads fails', async () => {
|
|
client.makeRequest = vi.fn().mockRejectedValue(new Error('boom'));
|
|
await client.getActiveDownloads();
|
|
expect(client.getLastError()).not.toBeNull();
|
|
expect(client.getLastError().operation).toBe('getActiveDownloads');
|
|
expect(client.getLastError().message).toBe('boom');
|
|
});
|
|
|
|
it('clears lastError after a subsequent successful call', async () => {
|
|
client.makeRequest = vi.fn().mockRejectedValue(new Error('boom'));
|
|
await client.getActiveDownloads();
|
|
expect(client.getLastError()).not.toBeNull();
|
|
|
|
client.makeRequest = vi.fn()
|
|
.mockResolvedValueOnce({ data: { queue: { slots: [], kbpersec: 0 } } })
|
|
.mockResolvedValueOnce({ data: { history: { slots: [] } } });
|
|
await client.getActiveDownloads();
|
|
expect(client.getLastError()).toBeNull();
|
|
});
|
|
});
|
|
});
|