// Copyright (c) 2026 Gordon Bolton. MIT License. const TransmissionClient = require('../../../server/clients/TransmissionClient'); const axios = require('axios'); const { vi } = require('vitest'); // Mock axios vi.mock('axios'); vi.mock('../../../server/utils/logger', () => ({ logToFile: vi.fn() })); describe('TransmissionClient', () => { let client; let mockConfig; beforeEach(() => { mockConfig = { id: 'test-transmission', name: 'Test Transmission', url: 'http://localhost:9091', username: 'transmission', password: 'transmission' }; client = new TransmissionClient(mockConfig); // Clear all mocks vi.clearAllMocks(); }); describe('Constructor', () => { it('should initialize with correct properties', () => { expect(client.getClientType()).toBe('transmission'); expect(client.getInstanceId()).toBe('test-transmission'); expect(client.name).toBe('Test Transmission'); expect(client.url).toBe('http://localhost:9091'); expect(client.sessionId).toBeNull(); expect(client.rpcUrl).toBe('http://localhost:9091/transmission/rpc'); }); }); describe('Connection Test', () => { it('should test connection successfully', async () => { const mockResponse = { data: { result: 'success', arguments: {} } }; client.makeRequest = vi.fn().mockResolvedValue(mockResponse); const result = await client.testConnection(); expect(result).toBe(true); expect(client.makeRequest).toHaveBeenCalledWith('session-get'); }); 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('RPC Requests', () => { it('should make RPC request with session ID', async () => { const mockResponse = { data: { result: 'success', arguments: { torrents: [] } } }; client.sessionId = 'test-session-id'; axios.post.mockResolvedValue(mockResponse); const result = await client.makeRequest('torrent-get', { fields: ['id', 'name'] }); expect(axios.post).toHaveBeenCalledWith( 'http://localhost:9091/transmission/rpc', { method: 'torrent-get', arguments: { fields: ['id', 'name'] } }, { headers: { 'Content-Type': 'application/json', 'X-Transmission-Session-Id': 'test-session-id' } } ); expect(result).toEqual(mockResponse); }); it('should handle session ID conflict (409)', async () => { const conflictError = { response: { status: 409, headers: { 'x-transmission-session-id': 'new-session-id' } } }; const successResponse = { data: { result: 'success', arguments: {} } }; axios.post .mockRejectedValueOnce(conflictError) .mockResolvedValueOnce(successResponse); const result = await client.makeRequest('session-get'); expect(client.sessionId).toBe('new-session-id'); expect(result).toEqual(successResponse); }); it('should handle RPC errors', async () => { const errorResponse = { data: { result: 'error', 'error-message': 'Invalid request' } }; axios.post.mockResolvedValue(errorResponse); await expect(client.makeRequest('invalid-method')).rejects.toThrow('Transmission RPC error: error'); }); }); describe('Download Normalization', () => { it('should normalize torrent data correctly', () => { const torrent = { id: 1, hashString: 'abc123', name: 'Test Torrent', status: 4, // downloading totalSize: 1000000000, sizeWhenDone: 1000000000, leftUntilDone: 250000000, rateDownload: 1048576, rateUpload: 0, eta: 3600, downloadedEver: 750000000, uploadedEver: 0, percentDone: 0.75, addedDate: 1640995200, doneDate: 0, labels: ['movies', 'hd'], downloadDir: '/downloads/test' }; const normalized = client.normalizeDownload(torrent); expect(normalized).toEqual({ id: 'abc123', title: 'Test Torrent', type: 'torrent', client: 'transmission', instanceId: 'test-transmission', instanceName: 'Test Transmission', status: 'Downloading', progress: 75, size: 1000000000, downloaded: 750000000, speed: 1048576, eta: 3600, category: 'movies', tags: ['movies', 'hd'], savePath: '/downloads/test', addedOn: '2022-01-01T00:00:00.000Z', raw: torrent }); }); it('should handle different torrent statuses', () => { const statusMap = { 0: 'Stopped', 1: 'Queued', 2: 'Checking', 3: 'Queued', 4: 'Downloading', 5: 'Queued', 6: 'Seeding', 7: 'Unknown' }; Object.entries(statusMap).forEach(([status, expectedStatus]) => { const torrent = { id: 1, hashString: 'abc123', name: 'Test', status: parseInt(status), totalSize: 1000000, sizeWhenDone: 1000000, leftUntilDone: 500000, rateDownload: 0, eta: -1, percentDone: 0.5, labels: [] }; const normalized = client.normalizeDownload(torrent); expect(normalized.status).toBe(expectedStatus); }); }); it('should handle unknown ETA values', () => { const testCases = [ { eta: -1, expected: null }, { eta: -2, expected: null }, { eta: 3600, expected: 3600 } ]; testCases.forEach(({ eta, expected }) => { const torrent = { id: 1, hashString: 'abc123', name: 'Test', status: 4, totalSize: 1000000, sizeWhenDone: 1000000, leftUntilDone: 500000, rateDownload: 0, eta: eta, percentDone: 0.5, labels: [] }; const normalized = client.normalizeDownload(torrent); expect(normalized.eta).toBe(expected); }); }); it('should extract category from first label', () => { const torrent = { id: 1, hashString: 'abc123', name: 'Test', status: 4, totalSize: 1000000, sizeWhenDone: 1000000, leftUntilDone: 500000, rateDownload: 0, eta: -1, percentDone: 0.5, labels: ['movies', 'hd', '4k'] }; const normalized = client.normalizeDownload(torrent); expect(normalized.category).toBe('movies'); expect(normalized.tags).toEqual(['movies', 'hd', '4k']); }); it('should handle empty labels', () => { const torrent = { id: 1, hashString: 'abc123', name: 'Test', status: 4, totalSize: 1000000, sizeWhenDone: 1000000, leftUntilDone: 500000, rateDownload: 0, eta: -1, percentDone: 0.5, labels: [] }; const normalized = client.normalizeDownload(torrent); expect(normalized.category).toBeUndefined(); expect(normalized.tags).toEqual([]); }); }); describe('Active Downloads', () => { it('should fetch and normalize torrents', async () => { const mockResponse = { data: { result: 'success', arguments: { torrents: [ { id: 1, hashString: 'abc123', name: 'Test Torrent 1', status: 4, totalSize: 1000000, sizeWhenDone: 1000000, leftUntilDone: 500000, rateDownload: 1048576, eta: 3600, percentDone: 0.5, labels: ['test'] }, { id: 2, hashString: 'def456', name: 'Test Torrent 2', status: 6, totalSize: 2000000, sizeWhenDone: 2000000, leftUntilDone: 0, rateDownload: 0, eta: -1, percentDone: 1.0, labels: [] } ] } } }; client.makeRequest = vi.fn().mockResolvedValue(mockResponse); const downloads = await client.getActiveDownloads(); expect(client.makeRequest).toHaveBeenCalledWith('torrent-get', { fields: [ 'id', 'name', 'hashString', 'status', 'totalSize', 'sizeWhenDone', 'leftUntilDone', 'rateDownload', 'rateUpload', 'eta', 'downloadedEver', 'uploadedEver', 'percentDone', 'addedDate', 'doneDate', 'trackerStats', 'labels', 'downloadDir', 'error', 'errorString', 'peersConnected', 'peersGettingFromUs', 'peersSendingToUs', 'queuePosition' ] }); expect(downloads).toHaveLength(2); expect(downloads[0].title).toBe('Test Torrent 1'); expect(downloads[0].status).toBe('Downloading'); expect(downloads[1].title).toBe('Test Torrent 2'); expect(downloads[1].status).toBe('Seeding'); }); it('should handle API errors gracefully', async () => { client.makeRequest = vi.fn().mockRejectedValue(new Error('API Error')); const downloads = await client.getActiveDownloads(); expect(downloads).toEqual([]); }); it('should handle empty torrent list', async () => { const mockResponse = { data: { result: 'success', arguments: { torrents: [] } } }; client.makeRequest = vi.fn().mockResolvedValue(mockResponse); const downloads = await client.getActiveDownloads(); expect(downloads).toEqual([]); }); }); describe('Client Status', () => { it('should get client status and session stats', async () => { const mockSessionResponse = { data: { result: 'success', arguments: { 'download-dir': '/downloads', 'peer-port': 51413, 'rpc-version': 15 } } }; const mockStatsResponse = { data: { result: 'success', arguments: { 'downloaded-bytes': 1000000000, 'uploaded-bytes': 500000000, 'torrent-count': 5 } } }; client.makeRequest = vi.fn() .mockResolvedValueOnce(mockSessionResponse) .mockResolvedValueOnce(mockStatsResponse); const status = await client.getClientStatus(); expect(client.makeRequest).toHaveBeenCalledWith('session-get'); expect(client.makeRequest).toHaveBeenCalledWith('session-stats'); expect(status).toEqual({ session: mockSessionResponse.data.arguments, stats: mockStatsResponse.data.arguments }); }); 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('ARR Info Extraction', () => { it('should extract series info from filename', () => { const torrent = { id: 1, hashString: 'abc123', name: 'Show Name - S01E02 - Episode Title', status: 4, totalSize: 1000000, sizeWhenDone: 1000000, leftUntilDone: 500000, rateDownload: 0, eta: -1, percentDone: 0.5, labels: [] }; const normalized = client.normalizeDownload(torrent); expect(normalized.arrType).toBe('series'); }); it('should extract movie info from filename', () => { const torrent = { id: 1, hashString: 'abc123', name: 'Movie Title (2023) 1080p', status: 4, totalSize: 1000000, sizeWhenDone: 1000000, leftUntilDone: 500000, rateDownload: 0, eta: -1, percentDone: 0.5, labels: [] }; const normalized = client.normalizeDownload(torrent); expect(normalized.arrType).toBe('movie'); }); it('should not extract ARR info from generic filename', () => { const torrent = { id: 1, hashString: 'abc123', name: 'Generic File Name.mkv', status: 4, totalSize: 1000000, sizeWhenDone: 1000000, leftUntilDone: 500000, rateDownload: 0, eta: -1, percentDone: 0.5, labels: [] }; const normalized = client.normalizeDownload(torrent); expect(normalized.arrType).toBeUndefined(); }); }); });