// Copyright (c) 2026 Gordon Bolton. MIT License. const RTorrentClient = require('../../../server/clients/RTorrentClient'); const xmlrpc = require('xmlrpc'); jest.mock('xmlrpc', () => ({ createClient: jest.fn() })); jest.mock('../../../server/utils/logger', () => ({ logToFile: jest.fn() })); describe('RTorrentClient', () => { let client; let mockConfig; let mockMethodCall; beforeEach(() => { mockMethodCall = jest.fn(); xmlrpc.createClient.mockReturnValue({ methodCall: mockMethodCall }); mockConfig = { id: 'test-rtorrent', name: 'Test rTorrent', url: 'http://localhost:8080', username: 'rtorrent', password: 'rtorrent' }; client = new RTorrentClient(mockConfig); jest.clearAllMocks(); }); 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 exact URL from config (no auto-append)', () => { expect(xmlrpc.createClient).toHaveBeenCalledWith({ url: 'http://localhost:8080', headers: { Authorization: `Basic ${Buffer.from('rtorrent:rtorrent').toString('base64')}` } }); }); it('should create xmlrpc client without auth when no credentials', () => { xmlrpc.createClient.mockClear(); const noAuthConfig = { id: 'test-rtorrent-noauth', name: 'Test rTorrent No Auth', url: 'http://localhost:8080/RPC2' }; new RTorrentClient(noAuthConfig); expect(xmlrpc.createClient).toHaveBeenCalledWith({ url: 'http://localhost:8080/RPC2' }); }); it('should use whatbox.ca-style /xmlrpc path exactly as configured', () => { xmlrpc.createClient.mockClear(); const whatboxConfig = { id: 'test-whatbox', name: 'Whatbox', url: 'https://user.whatbox.ca/xmlrpc', username: 'user', password: 'pass' }; new RTorrentClient(whatboxConfig); expect(xmlrpc.createClient).toHaveBeenCalledWith({ url: 'https://user.whatbox.ca/xmlrpc', headers: { Authorization: `Basic ${Buffer.from('user:pass').toString('base64')}` } }); }); it('should use custom RPC path exactly as configured', () => { xmlrpc.createClient.mockClear(); const customConfig = { id: 'test-custom', name: 'Custom', url: 'https://example.com/custom/rpc/path' }; new RTorrentClient(customConfig); expect(xmlrpc.createClient).toHaveBeenCalledWith({ url: 'https://example.com/custom/rpc/path' }); }); }); 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); expect(mockMethodCall).toHaveBeenCalledWith( 'system.client_version', [], expect.any(Function) ); }); 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: 476, 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(); }); }); });