All checks were successful
Build and Push Docker Image / build (push) Successful in 24s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 56s
CI / Security audit (push) Successful in 1m12s
CI / Tests & coverage (push) Successful in 1m25s
Docs Check / Markdown lint (pull_request) Successful in 41s
Licence Check / Licence compatibility and copyright header verification (pull_request) Successful in 1m14s
CI / Security audit (pull_request) Successful in 1m33s
Docs Check / Mermaid diagram parse check (pull_request) Successful in 1m56s
CI / Tests & coverage (pull_request) Successful in 2m3s
- Replace vi.mock('axios') with nock for HTTP request mocking (ES/CJS interop issue)
- Fix RTorrentClient by mocking client.client.methodCall directly instead of xmlrpc module
- Fix downloadClients.test.js by manually adding mock clients to registry
- Fix qbittorrent.test.js to use getActiveDownloads() and normalized properties
- Fix integration test env var mocks and error assertions
- Fix SABnzbdClient size parsing and test fixtures
- Fix RTorrentClient ETA calculation expectation
All 261 tests now passing.
424 lines
11 KiB
JavaScript
424 lines
11 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
import RTorrentClient from '../../../server/clients/RTorrentClient.js';
|
|
import { vi } from 'vitest';
|
|
|
|
vi.mock('../../../server/utils/logger', () => ({
|
|
logToFile: vi.fn()
|
|
}));
|
|
|
|
describe('RTorrentClient', () => {
|
|
let client;
|
|
let mockConfig;
|
|
let mockMethodCall;
|
|
|
|
beforeEach(() => {
|
|
mockMethodCall = vi.fn();
|
|
|
|
mockConfig = {
|
|
id: 'test-rtorrent',
|
|
name: 'Test rTorrent',
|
|
url: 'http://localhost:8080',
|
|
username: 'rtorrent',
|
|
password: 'rtorrent'
|
|
};
|
|
|
|
client = new RTorrentClient(mockConfig);
|
|
// Mock the xmlrpc client's methodCall directly
|
|
client.client.methodCall = mockMethodCall;
|
|
});
|
|
|
|
describe('Constructor', () => {
|
|
it('should initialize with correct properties', () => {
|
|
expect(client.getClientType()).toBe('rtorrent');
|
|
expect(client.getInstanceId()).toBe('test-rtorrent');
|
|
expect(client.name).toBe('Test rTorrent');
|
|
expect(client.url).toBe('http://localhost:8080');
|
|
});
|
|
|
|
it('should create xmlrpc client with correct URL', async () => {
|
|
expect(client.url).toBe('http://localhost:8080');
|
|
expect(client.client).toBeDefined();
|
|
});
|
|
|
|
it('should create xmlrpc client without auth when no credentials', () => {
|
|
const noAuthConfig = {
|
|
id: 'test-rtorrent-noauth',
|
|
name: 'Test rTorrent No Auth',
|
|
url: 'http://localhost:8080/RPC2'
|
|
};
|
|
const clientNoAuth = new RTorrentClient(noAuthConfig);
|
|
expect(clientNoAuth.client).toBeDefined();
|
|
});
|
|
|
|
it('should use whatbox.ca-style /xmlrpc path exactly as configured', () => {
|
|
const whatboxConfig = {
|
|
id: 'test-whatbox',
|
|
name: 'Whatbox',
|
|
url: 'https://user.whatbox.ca/xmlrpc',
|
|
username: 'user',
|
|
password: 'pass'
|
|
};
|
|
const clientWhatbox = new RTorrentClient(whatboxConfig);
|
|
expect(clientWhatbox.client).toBeDefined();
|
|
});
|
|
|
|
it('should use custom RPC path exactly as configured', () => {
|
|
const customConfig = {
|
|
id: 'test-custom',
|
|
name: 'Custom',
|
|
url: 'https://example.com/custom/rpc/path'
|
|
};
|
|
const clientCustom = new RTorrentClient(customConfig);
|
|
expect(clientCustom.client).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('Connection Test', () => {
|
|
it('should test connection successfully', async () => {
|
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
|
callback(null, '0.9.8');
|
|
});
|
|
|
|
const result = await client.testConnection();
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should handle connection test failure', async () => {
|
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
|
callback(new Error('Connection refused'));
|
|
});
|
|
|
|
const result = await client.testConnection();
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getActiveDownloads', () => {
|
|
it('should fetch and normalize torrents', async () => {
|
|
const mockTorrents = [
|
|
[
|
|
'abc123def456',
|
|
'Test Torrent 1',
|
|
1000000000,
|
|
750000000,
|
|
1048576,
|
|
0,
|
|
1,
|
|
1,
|
|
0,
|
|
'/downloads/test',
|
|
'movies'
|
|
],
|
|
[
|
|
'def789abc012',
|
|
'Test Torrent 2',
|
|
2000000000,
|
|
2000000000,
|
|
0,
|
|
512000,
|
|
1,
|
|
1,
|
|
0,
|
|
'/downloads/complete',
|
|
'tv'
|
|
]
|
|
];
|
|
|
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
|
callback(null, mockTorrents);
|
|
});
|
|
|
|
const downloads = await client.getActiveDownloads();
|
|
|
|
expect(downloads).toHaveLength(2);
|
|
expect(downloads[0].id).toBe('abc123def456');
|
|
expect(downloads[0].title).toBe('Test Torrent 1');
|
|
expect(downloads[0].status).toBe('Downloading');
|
|
expect(downloads[0].progress).toBe(75);
|
|
expect(downloads[0].category).toBe('movies');
|
|
expect(downloads[1].status).toBe('Seeding');
|
|
expect(downloads[1].category).toBe('tv');
|
|
});
|
|
|
|
it('should handle empty torrent list', async () => {
|
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
|
callback(null, []);
|
|
});
|
|
|
|
const downloads = await client.getActiveDownloads();
|
|
|
|
expect(downloads).toEqual([]);
|
|
});
|
|
|
|
it('should handle XML-RPC errors gracefully', async () => {
|
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
|
callback(new Error('XML-RPC fault'));
|
|
});
|
|
|
|
const downloads = await client.getActiveDownloads();
|
|
|
|
expect(downloads).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('normalizeDownload', () => {
|
|
it('should normalize a downloading torrent', () => {
|
|
const torrent = [
|
|
'hash123',
|
|
'Downloading Torrent',
|
|
1000000000,
|
|
500000000,
|
|
1048576,
|
|
0,
|
|
1,
|
|
1,
|
|
0,
|
|
'/downloads',
|
|
''
|
|
];
|
|
|
|
const normalized = client.normalizeDownload(torrent);
|
|
|
|
expect(normalized).toEqual({
|
|
id: 'hash123',
|
|
title: 'Downloading Torrent',
|
|
type: 'torrent',
|
|
client: 'rtorrent',
|
|
instanceId: 'test-rtorrent',
|
|
instanceName: 'Test rTorrent',
|
|
status: 'Downloading',
|
|
progress: 50,
|
|
size: 1000000000,
|
|
downloaded: 500000000,
|
|
speed: 1048576,
|
|
eta: 477,
|
|
category: undefined,
|
|
tags: [],
|
|
savePath: '/downloads',
|
|
addedOn: undefined,
|
|
arrQueueId: undefined,
|
|
arrType: undefined,
|
|
raw: torrent
|
|
});
|
|
});
|
|
|
|
it('should normalize a seeding torrent', () => {
|
|
const torrent = [
|
|
'hash456',
|
|
'Seeding Torrent',
|
|
500000000,
|
|
500000000,
|
|
0,
|
|
204800,
|
|
1,
|
|
1,
|
|
0,
|
|
'/downloads/complete',
|
|
'movies'
|
|
];
|
|
|
|
const normalized = client.normalizeDownload(torrent);
|
|
|
|
expect(normalized.status).toBe('Seeding');
|
|
expect(normalized.progress).toBe(100);
|
|
expect(normalized.speed).toBe(204800);
|
|
expect(normalized.eta).toBeNull();
|
|
expect(normalized.category).toBe('movies');
|
|
expect(normalized.tags).toEqual(['movies']);
|
|
});
|
|
|
|
it('should normalize a paused torrent', () => {
|
|
const torrent = [
|
|
'hash789',
|
|
'Paused Torrent',
|
|
1000000000,
|
|
250000000,
|
|
0,
|
|
0,
|
|
1,
|
|
0,
|
|
0,
|
|
'/downloads',
|
|
''
|
|
];
|
|
|
|
const normalized = client.normalizeDownload(torrent);
|
|
|
|
expect(normalized.status).toBe('Paused');
|
|
expect(normalized.speed).toBe(0);
|
|
expect(normalized.eta).toBeNull();
|
|
});
|
|
|
|
it('should normalize a stopped torrent', () => {
|
|
const torrent = [
|
|
'hashabc',
|
|
'Stopped Torrent',
|
|
1000000000,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
'/downloads',
|
|
''
|
|
];
|
|
|
|
const normalized = client.normalizeDownload(torrent);
|
|
|
|
expect(normalized.status).toBe('Stopped');
|
|
});
|
|
|
|
it('should normalize a checking torrent', () => {
|
|
const torrent = [
|
|
'hashdef',
|
|
'Checking Torrent',
|
|
1000000000,
|
|
500000000,
|
|
0,
|
|
0,
|
|
1,
|
|
0,
|
|
1,
|
|
'/downloads',
|
|
''
|
|
];
|
|
|
|
const normalized = client.normalizeDownload(torrent);
|
|
|
|
expect(normalized.status).toBe('Checking');
|
|
});
|
|
|
|
it('should handle zero-size torrent', () => {
|
|
const torrent = [
|
|
'hash000',
|
|
'Zero Size',
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
1,
|
|
1,
|
|
0,
|
|
'/downloads',
|
|
''
|
|
];
|
|
|
|
const normalized = client.normalizeDownload(torrent);
|
|
|
|
expect(normalized.progress).toBe(0);
|
|
expect(normalized.size).toBe(0);
|
|
expect(normalized.downloaded).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Status Mapping', () => {
|
|
const testCases = [
|
|
{ state: 0, isActive: 0, isHashChecking: 0, completed: 0, size: 100, expected: 'Stopped' },
|
|
{ state: 1, isActive: 1, isHashChecking: 0, completed: 50, size: 100, expected: 'Downloading' },
|
|
{ state: 1, isActive: 1, isHashChecking: 0, completed: 100, size: 100, expected: 'Seeding' },
|
|
{ state: 1, isActive: 0, isHashChecking: 0, completed: 50, size: 100, expected: 'Paused' },
|
|
{ state: 1, isActive: 0, isHashChecking: 0, completed: 100, size: 100, expected: 'Paused' },
|
|
{ state: 1, isActive: 0, isHashChecking: 1, completed: 50, size: 100, expected: 'Checking' },
|
|
{ state: 1, isActive: 1, isHashChecking: 1, completed: 50, size: 100, expected: 'Checking' }
|
|
];
|
|
|
|
testCases.forEach(({ state, isActive, isHashChecking, completed, size, expected }) => {
|
|
it(`should map state=${state} isActive=${isActive} isHashChecking=${isHashChecking} to ${expected}`, () => {
|
|
const status = client._mapStatus(state, isActive, isHashChecking, completed, size);
|
|
expect(status).toBe(expected);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ARR Info Extraction', () => {
|
|
it('should extract series info from filename', () => {
|
|
const torrent = [
|
|
'hash123',
|
|
'Show Name - S01E02 - Episode Title',
|
|
1000000000,
|
|
500000000,
|
|
1048576,
|
|
0,
|
|
1,
|
|
1,
|
|
0,
|
|
'/downloads',
|
|
''
|
|
];
|
|
|
|
const normalized = client.normalizeDownload(torrent);
|
|
expect(normalized.arrType).toBe('series');
|
|
});
|
|
|
|
it('should extract movie info from filename', () => {
|
|
const torrent = [
|
|
'hash456',
|
|
'Movie Title (2023) 1080p',
|
|
2000000000,
|
|
1000000000,
|
|
1048576,
|
|
0,
|
|
1,
|
|
1,
|
|
0,
|
|
'/downloads',
|
|
''
|
|
];
|
|
|
|
const normalized = client.normalizeDownload(torrent);
|
|
expect(normalized.arrType).toBe('movie');
|
|
});
|
|
|
|
it('should not extract ARR info from generic filename', () => {
|
|
const torrent = [
|
|
'hash789',
|
|
'Generic File Name.mkv',
|
|
1000000000,
|
|
500000000,
|
|
1048576,
|
|
0,
|
|
1,
|
|
1,
|
|
0,
|
|
'/downloads',
|
|
''
|
|
];
|
|
|
|
const normalized = client.normalizeDownload(torrent);
|
|
expect(normalized.arrType).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Client Status', () => {
|
|
it('should get client status', async () => {
|
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
|
if (method === 'throttle.global_down.rate') {
|
|
callback(null, 1048576);
|
|
} else if (method === 'throttle.global_up.rate') {
|
|
callback(null, 512000);
|
|
}
|
|
});
|
|
|
|
const status = await client.getClientStatus();
|
|
|
|
expect(status).toEqual({
|
|
globalDownRate: 1048576,
|
|
globalUpRate: 512000
|
|
});
|
|
});
|
|
|
|
it('should handle status request errors', async () => {
|
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
|
callback(new Error('Status error'));
|
|
});
|
|
|
|
const status = await client.getClientStatus();
|
|
|
|
expect(status).toBeNull();
|
|
});
|
|
});
|
|
});
|