/** * Component: Transmission Integration Service Tests * Documentation: documentation/phase3/download-clients.md */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TransmissionService } from '@/lib/integrations/transmission.service'; const clientMock = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), })); const axiosMock = vi.hoisted(() => ({ create: vi.fn(() => clientMock), post: vi.fn(), get: vi.fn(), isAxiosError: (error: any) => Boolean(error?.isAxiosError), })); const parseTorrentMock = vi.hoisted(() => vi.fn()); vi.mock('axios', () => ({ default: axiosMock, ...axiosMock, })); vi.mock('parse-torrent', () => ({ default: parseTorrentMock, })); describe('TransmissionService', () => { beforeEach(() => { vi.clearAllMocks(); clientMock.get.mockReset(); clientMock.post.mockReset(); axiosMock.get.mockReset(); axiosMock.post.mockReset(); parseTorrentMock.mockReset(); }); describe('constructor', () => { it('sets clientType and protocol correctly', () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); expect(service.clientType).toBe('transmission'); expect(service.protocol).toBe('torrent'); }); }); describe('testConnection', () => { it('returns success with version on valid connection', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); clientMock.post.mockResolvedValueOnce({ data: { result: 'success', arguments: { version: '4.0.5' } }, }); const result = await service.testConnection(); expect(result.success).toBe(true); expect(result.version).toBe('4.0.5'); expect(result.message).toContain('Transmission'); }); it('returns failure when RPC returns error', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); clientMock.post.mockResolvedValueOnce({ data: { result: 'unauthorized' }, }); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('unauthorized'); }); it('returns failure on connection error', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); clientMock.post.mockRejectedValueOnce(new Error('Connection refused')); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('Connection refused'); }); it('returns SSL-specific errors', async () => { const service = new TransmissionService('https://transmission', 'user', 'pass'); clientMock.post.mockRejectedValueOnce({ isAxiosError: true, code: 'DEPTH_ZERO_SELF_SIGNED_CERT', message: 'self signed', }); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('SSL certificate verification failed'); }); it('returns ECONNREFUSED error with URL', async () => { const service = new TransmissionService('http://transmission:9091', 'user', 'pass'); clientMock.post.mockRejectedValueOnce({ isAxiosError: true, code: 'ECONNREFUSED', message: 'refused', }); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('Connection refused'); expect(result.message).toContain('http://transmission:9091'); }); it('returns 401 authentication error', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); clientMock.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 }, message: 'Unauthorized', }); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.message).toContain('Authentication failed'); }); }); describe('CSRF handling', () => { it('captures X-Transmission-Session-Id on 409 and retries', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); // First call returns 409 with session ID clientMock.post .mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, headers: { 'x-transmission-session-id': 'csrf-token-123' }, }, }) // Retry succeeds .mockResolvedValueOnce({ data: { result: 'success', arguments: { version: '4.0.5' } }, }); const result = await service.testConnection(); expect(result.success).toBe(true); expect(clientMock.post).toHaveBeenCalledTimes(2); // Verify second call includes the session ID header const secondCall = clientMock.post.mock.calls[1]; expect(secondCall[2].headers['X-Transmission-Session-Id']).toBe('csrf-token-123'); }); }); describe('addDownload', () => { it('rejects empty URLs', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); await expect(service.addDownload('')).rejects.toThrow('Invalid download URL'); }); it('adds magnet links via torrent-add RPC', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); // getTorrentByHash - not found (no duplicate) clientMock.post .mockResolvedValueOnce({ data: { result: 'success', arguments: { torrents: [] } }, }) // torrent-add .mockResolvedValueOnce({ data: { result: 'success', arguments: { 'torrent-added': { hashString: '0123456789abcdef0123456789abcdef01234567', name: 'Test' }, }, }, }); const hash = await service.addDownload( 'magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567' ); expect(hash).toBe('0123456789abcdef0123456789abcdef01234567'); }); it('skips duplicate magnet links', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); // getTorrentByHash - found (duplicate) clientMock.post.mockResolvedValueOnce({ data: { result: 'success', arguments: { torrents: [{ hashString: '0123456789abcdef0123456789abcdef01234567', name: 'Existing', }], }, }, }); const hash = await service.addDownload( 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567' ); expect(hash).toBe('0123456789abcdef0123456789abcdef01234567'); // Only 1 RPC call (torrent-get), no torrent-add expect(clientMock.post).toHaveBeenCalledTimes(1); }); it('throws on invalid magnet link', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); await expect(service.addDownload('magnet:?xt=urn:btih:')).rejects.toThrow('Invalid magnet link'); }); it('throws when Transmission rejects the magnet link', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); // No duplicate clientMock.post .mockResolvedValueOnce({ data: { result: 'success', arguments: { torrents: [] } }, }) // torrent-add fails .mockResolvedValueOnce({ data: { result: 'duplicate torrent' }, }); await expect( service.addDownload('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567') ).rejects.toThrow('Transmission rejected magnet link'); }); it('adds .torrent files via metainfo base64', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent-data') }); parseTorrentMock.mockResolvedValueOnce({ infoHash: 'parsed-hash', name: 'Book' }); // getTorrentByHash - not found clientMock.post .mockResolvedValueOnce({ data: { result: 'success', arguments: { torrents: [] } }, }) // torrent-add succeeds .mockResolvedValueOnce({ data: { result: 'success', arguments: { 'torrent-added': { hashString: 'parsed-hash', name: 'Book' }, }, }, }); const hash = await service.addDownload('http://example.com/file.torrent'); expect(hash).toBe('parsed-hash'); // Verify metainfo was sent const addCall = clientMock.post.mock.calls[1]; const body = addCall[0] === '/transmission/rpc' ? JSON.parse(JSON.stringify(addCall[1])) : null; // The body should be the RPC call with metainfo }); it('follows redirect to magnet link', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); axiosMock.get.mockRejectedValueOnce({ isAxiosError: true, response: { status: 302, headers: { location: 'magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01' }, }, }); // getTorrentByHash - not found clientMock.post .mockResolvedValueOnce({ data: { result: 'success', arguments: { torrents: [] } }, }) // torrent-add .mockResolvedValueOnce({ data: { result: 'success', arguments: { 'torrent-added': { hashString: 'abcdef0123456789abcdef0123456789abcdef01', name: 'Test' }, }, }, }); const hash = await service.addDownload('http://example.com/file.torrent'); expect(hash).toBe('abcdef0123456789abcdef0123456789abcdef01'); }); it('throws on invalid .torrent file', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('not-a-torrent') }); parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent')); await expect(service.addDownload('http://example.com/file.torrent')).rejects.toThrow( 'Invalid .torrent file - failed to parse' ); }); }); describe('getDownload', () => { it('returns mapped DownloadInfo for found torrents', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); clientMock.post.mockResolvedValueOnce({ data: { result: 'success', arguments: { torrents: [{ hashString: 'abc123', name: 'Audiobook', totalSize: 1000, downloadedEver: 500, percentDone: 0.5, status: 4, // downloading rateDownload: 1000, eta: 500, labels: ['readmeabook'], downloadDir: '/downloads', doneDate: 0, errorString: '', error: 0, secondsSeeding: 3600, uploadRatio: 0.1, uploadedEver: 50, }], }, }, }); const info = await service.getDownload('abc123'); expect(info).not.toBeNull(); expect(info!.id).toBe('abc123'); expect(info!.name).toBe('Audiobook'); expect(info!.status).toBe('downloading'); expect(info!.progress).toBe(0.5); expect(info!.downloadSpeed).toBe(1000); expect(info!.category).toBe('readmeabook'); expect(info!.seedingTime).toBe(3600); }); it('returns null when torrent not found after retries', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); // All retries return empty clientMock.post.mockResolvedValue({ data: { result: 'success', arguments: { torrents: [] } }, }); const info = await service.getDownload('nonexistent'); expect(info).toBeNull(); }); it('maps error code to failed status', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); clientMock.post.mockResolvedValueOnce({ data: { result: 'success', arguments: { torrents: [{ hashString: 'abc123', name: 'Failed', totalSize: 1000, downloadedEver: 0, percentDone: 0, status: 0, rateDownload: 0, eta: -1, labels: [], downloadDir: '/downloads', doneDate: 0, errorString: 'Tracker error', error: 2, uploadRatio: -1, uploadedEver: 0, }], }, }, }); const info = await service.getDownload('abc123'); expect(info).not.toBeNull(); expect(info!.status).toBe('failed'); expect(info!.errorMessage).toBe('Tracker error'); }); }); describe('status mapping', () => { const makeService = () => new TransmissionService('http://transmission', '', ''); const mapStatus = (service: TransmissionService, status: number, error = 0) => { return (service as any).mapStatus(status, error); }; it('maps 0 (stopped) to paused', () => { expect(mapStatus(makeService(), 0)).toBe('paused'); }); it('maps 1 (check-pending) to checking', () => { expect(mapStatus(makeService(), 1)).toBe('checking'); }); it('maps 2 (checking) to checking', () => { expect(mapStatus(makeService(), 2)).toBe('checking'); }); it('maps 3 (download-pending) to queued', () => { expect(mapStatus(makeService(), 3)).toBe('queued'); }); it('maps 4 (downloading) to downloading', () => { expect(mapStatus(makeService(), 4)).toBe('downloading'); }); it('maps 5 (seed-pending) to seeding', () => { expect(mapStatus(makeService(), 5)).toBe('seeding'); }); it('maps 6 (seeding) to seeding', () => { expect(mapStatus(makeService(), 6)).toBe('seeding'); }); it('maps any status with error > 0 to failed', () => { expect(mapStatus(makeService(), 4, 1)).toBe('failed'); expect(mapStatus(makeService(), 6, 2)).toBe('failed'); }); }); describe('pauseDownload / resumeDownload / deleteDownload', () => { it('pauses torrents via torrent-stop', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); clientMock.post .mockResolvedValueOnce({ data: { result: 'success', arguments: { torrents: [{ hashString: 'hash-1', name: 'Test' }], }, }, }) .mockResolvedValueOnce({ data: { result: 'success' } }); await service.pauseDownload('hash-1'); const stopCall = clientMock.post.mock.calls[1]; expect(stopCall[1]).toEqual( expect.objectContaining({ method: 'torrent-stop' }) ); }); it('resumes torrents via torrent-start', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); clientMock.post .mockResolvedValueOnce({ data: { result: 'success', arguments: { torrents: [{ hashString: 'hash-1', name: 'Test' }], }, }, }) .mockResolvedValueOnce({ data: { result: 'success' } }); await service.resumeDownload('hash-1'); const startCall = clientMock.post.mock.calls[1]; expect(startCall[1]).toEqual( expect.objectContaining({ method: 'torrent-start' }) ); }); it('deletes torrents via torrent-remove', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); clientMock.post .mockResolvedValueOnce({ data: { result: 'success', arguments: { torrents: [{ hashString: 'hash-1', name: 'Test' }], }, }, }) .mockResolvedValueOnce({ data: { result: 'success' } }); await service.deleteDownload('hash-1', true); const removeCall = clientMock.post.mock.calls[1]; expect(removeCall[1]).toEqual( expect.objectContaining({ method: 'torrent-remove', arguments: expect.objectContaining({ 'delete-local-data': true }), }) ); }); it('throws when pause fails', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); clientMock.post.mockRejectedValueOnce(new Error('fail')); await expect(service.pauseDownload('hash-1')).rejects.toThrow('Failed to pause torrent'); }); it('throws when resume fails', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); clientMock.post.mockRejectedValueOnce(new Error('fail')); await expect(service.resumeDownload('hash-1')).rejects.toThrow('Failed to resume torrent'); }); it('throws when delete fails', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); clientMock.post.mockRejectedValueOnce(new Error('fail')); await expect(service.deleteDownload('hash-1')).rejects.toThrow('Failed to delete torrent'); }); }); describe('postProcess', () => { it('is a no-op', async () => { const service = new TransmissionService('http://transmission', 'user', 'pass'); await expect(service.postProcess('hash-1')).resolves.toBeUndefined(); }); }); describe('path mapping', () => { it('applies reverse path mapping for torrent-add download-dir', async () => { const service = new TransmissionService( 'http://transmission', 'user', 'pass', '/downloads', 'readmeabook', false, { enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' } ); // No duplicate clientMock.post .mockResolvedValueOnce({ data: { result: 'success', arguments: { torrents: [] } }, }) // torrent-add .mockResolvedValueOnce({ data: { result: 'success', arguments: { 'torrent-added': { hashString: '0123456789abcdef0123456789abcdef01234567', name: 'Test' }, }, }, }); await service.addDownload('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'); // Verify the torrent-add call has the remote path const addCall = clientMock.post.mock.calls[1]; const rpcBody = addCall[1]; expect(rpcBody.arguments['download-dir']).toBe('F:\\Docker\\downloads'); }); }); });