// Copyright (c) 2026 Gordon Bolton. MIT License. /** * Tests for server/utils/qbittorrent.js pure utility functions. * * mapTorrentToDownload, formatBytes, formatSpeed, and formatEta are all * pure functions with no I/O — ideal unit test targets. These power the * dashboard card rendering so correctness matters for UX. */ import { mapTorrentToDownload, formatBytes, formatSpeed, formatEta } from '../../server/utils/qbittorrent.js'; import QBittorrentClient from '../../server/clients/QBittorrentClient.js'; import nock from 'nock'; // Minimal torrent fixture that satisfies mapTorrentToDownload's expectations function makeTorrent(overrides = {}) { return { name: 'My.Show.S01E01.1080p.mkv', state: 'downloading', size: 1073741824, // 1 GB completed: 536870912, // 512 MB progress: 0.5, dlspeed: 1048576, // 1 MB/s eta: 512, // seconds num_seeds: 10, num_leechs: 3, availability: 1.0, hash: 'aabbccdd', category: 'sonarr', tags: '', content_path: '/downloads/My.Show.S01E01.1080p.mkv', save_path: '/downloads/', instanceName: 'i3omb', ...overrides }; } const QBT_URL = 'http://qbittorrent.test:8080'; function makeClient(overrides = {}) { return new QBittorrentClient({ id: 'test-qbt', name: 'TestQBT', url: QBT_URL, username: 'admin', password: 'adminadmin', ...overrides }); } function mockLogin() { return nock(QBT_URL) .post('/api/v2/auth/login') .reply(200, {}, { 'set-cookie': ['SID=abc123; path=/'] }); } function mockSync(rid, response) { return nock(QBT_URL) .get(`/api/v2/sync/maindata?rid=${rid}`) .reply(200, response); } afterEach(() => { nock.cleanAll(); }); describe('formatBytes', () => { it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 B')); it('formats bytes', () => expect(formatBytes(512)).toBe('512 B')); it('formats kilobytes', () => expect(formatBytes(1024)).toBe('1 KB')); it('formats megabytes', () => expect(formatBytes(1048576)).toBe('1 MB')); it('formats gigabytes', () => expect(formatBytes(1073741824)).toBe('1 GB')); it('formats fractional GB', () => expect(formatBytes(1610612736)).toBe('1.5 GB')); }); describe('formatSpeed', () => { it('appends /s to byte count', () => expect(formatSpeed(1048576)).toBe('1 MB/s')); it('handles zero speed', () => expect(formatSpeed(0)).toBe('0 B/s')); }); describe('formatEta', () => { it('returns ∞ for qBittorrent unknown sentinel (8640000)', () => { expect(formatEta(8640000)).toBe('∞'); }); it('returns ∞ for negative eta', () => expect(formatEta(-1)).toBe('∞')); it('formats minutes only', () => expect(formatEta(90)).toBe('1m')); it('formats hours and minutes', () => expect(formatEta(3661)).toBe('1h 1m')); it('formats days, hours and minutes', () => expect(formatEta(90061)).toBe('1d 1h 1m')); it('returns 0m for zero seconds', () => expect(formatEta(0)).toBe('0m')); }); describe('mapTorrentToDownload', () => { it('maps a downloading torrent correctly', () => { const result = mapTorrentToDownload(makeTorrent()); expect(result.status).toBe('Downloading'); expect(result.progress).toBe('50.0'); expect(result.size).toBe('1 GB'); expect(result.speed).toBe('1 MB/s'); expect(result.eta).toBe('8m'); expect(result.seeds).toBe(10); expect(result.peers).toBe(3); expect(result.qbittorrent).toBe(true); expect(result.instanceName).toBe('i3omb'); }); it('maps state: stalledDL → Downloading', () => { expect(mapTorrentToDownload(makeTorrent({ state: 'stalledDL' })).status).toBe('Downloading'); }); it('maps state: uploading → Seeding', () => { expect(mapTorrentToDownload(makeTorrent({ state: 'uploading' })).status).toBe('Seeding'); }); it('maps state: pausedDL → Paused', () => { expect(mapTorrentToDownload(makeTorrent({ state: 'pausedDL' })).status).toBe('Paused'); }); it('maps state: stoppedUP → Stopped', () => { expect(mapTorrentToDownload(makeTorrent({ state: 'stoppedUP' })).status).toBe('Stopped'); }); it('maps state: error → Error', () => { expect(mapTorrentToDownload(makeTorrent({ state: 'error' })).status).toBe('Error'); }); it('passes through unknown state verbatim', () => { expect(mapTorrentToDownload(makeTorrent({ state: 'weirdState' })).status).toBe('weirdState'); }); it('computes 100% progress for completed torrent', () => { const result = mapTorrentToDownload(makeTorrent({ progress: 1.0 })); expect(result.progress).toBe('100.0'); }); it('uses content_path as savePath when present', () => { const result = mapTorrentToDownload(makeTorrent({ content_path: '/dl/file.mkv' })); expect(result.savePath).toBe('/dl/file.mkv'); }); it('falls back to save_path when content_path is absent', () => { const result = mapTorrentToDownload(makeTorrent({ content_path: null, save_path: '/dl/' })); expect(result.savePath).toBe('/dl/'); }); }); describe('QBittorrentClient sync API', () => { it('first call uses rid=0 and returns full torrent list', async () => { mockLogin(); const client = makeClient(); await client.login(); mockSync(0, { rid: 1, full_update: true, torrents: { hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 } } }); const torrents = await client.getActiveDownloads(); expect(torrents).toHaveLength(1); expect(torrents[0].title).toBe('Test1'); expect(torrents[0].instanceId).toBe('test-qbt'); expect(torrents[0].id).toBe('hash01'); expect(client.lastRid).toBe(1); }); it('subsequent call uses last rid and merges delta', async () => { mockLogin(); const client = makeClient(); await client.login(); // First call mockSync(0, { rid: 1, full_update: true, torrents: { hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 } } }); await client.getActiveDownloads(); // Second call — delta mockSync(1, { rid: 2, full_update: false, torrents: { hash01: { dlspeed: 200 } } }); const torrents = await client.getActiveDownloads(); expect(torrents).toHaveLength(1); expect(torrents[0].speed).toBe(200); expect(torrents[0].title).toBe('Test1'); expect(client.lastRid).toBe(2); }); it('handles full_update=true on subsequent call', async () => { mockLogin(); const client = makeClient(); await client.login(); // First call mockSync(0, { rid: 1, full_update: true, torrents: { hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 } } }); await client.getActiveDownloads(); // Server forces full refresh mockSync(1, { rid: 2, full_update: true, torrents: { hash02: { name: 'Test2', state: 'uploading', size: 2000, progress: 1.0, dlspeed: 0, eta: 0, num_seeds: 10, num_leechs: 0, availability: 1.0 } } }); const torrents = await client.getActiveDownloads(); expect(torrents).toHaveLength(1); expect(torrents[0].title).toBe('Test2'); expect(torrents[0].id).toBe('hash02'); expect(client.lastRid).toBe(2); }); it('removes torrents when torrents_removed is present', async () => { mockLogin(); const client = makeClient(); await client.login(); mockSync(0, { rid: 1, full_update: true, torrents: { hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 } } }); await client.getActiveDownloads(); mockSync(1, { rid: 2, full_update: false, torrents_removed: ['hash01'] }); const torrents = await client.getActiveDownloads(); expect(torrents).toHaveLength(0); }); it('falls back to torrents/info when sync fails', async () => { mockLogin(); const client = makeClient(); await client.login(); // Sync fails with 500 nock(QBT_URL) .get('/api/v2/sync/maindata?rid=0') .reply(500, { error: 'Internal Server Error' }); // Legacy succeeds nock(QBT_URL) .get('/api/v2/torrents/info') .reply(200, [ { name: 'Fallback', hash: 'fb01', state: 'downloading', size: 1073741824, progress: 0.5, dlspeed: 1048576, eta: 512, num_seeds: 10, num_leechs: 3, availability: 1.0 } ]); const torrents = await client.getActiveDownloads(); expect(torrents).toHaveLength(1); expect(torrents[0].title).toBe('Fallback'); expect(client.fallbackThisCycle).toBe(true); }); it('uses legacy immediately if already fell back this cycle', async () => { mockLogin(); const client = makeClient(); await client.login(); client.fallbackThisCycle = true; // Only legacy should be called nock(QBT_URL) .get('/api/v2/torrents/info') .reply(200, [ { name: 'DirectLegacy', hash: 'dl01', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 } ]); // Ensure sync is NOT called const syncScope = nock(QBT_URL) .get('/api/v2/sync/maindata?rid=0') .reply(200, { rid: 1, full_update: true }); const torrents = await client.getActiveDownloads(); expect(torrents).toHaveLength(1); expect(torrents[0].title).toBe('DirectLegacy'); expect(syncScope.isDone()).toBe(false); }); it('re-authenticates on 403 during sync and retries', async () => { mockLogin(); const client = makeClient(); await client.login(); // First sync call returns 403 nock(QBT_URL) .get('/api/v2/sync/maindata?rid=0') .reply(403, {}); // Re-login nock(QBT_URL) .post('/api/v2/auth/login') .reply(200, {}, { 'set-cookie': ['SID=newtoken; path=/'] }); // Retry succeeds nock(QBT_URL) .get('/api/v2/sync/maindata?rid=0') .reply(200, { rid: 1, full_update: true, torrents: { hash01: { name: 'AfterReauth', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 } } }); const torrents = await client.getActiveDownloads(); expect(torrents).toHaveLength(1); expect(torrents[0].title).toBe('AfterReauth'); }); it('computes completed from size and progress when missing', async () => { mockLogin(); const client = makeClient(); await client.login(); mockSync(0, { rid: 1, full_update: true, torrents: { hash01: { name: 'NoCompleted', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 } } }); const torrents = await client.getActiveDownloads(); expect(torrents[0].downloaded).toBe(500); }); it('resets fallback flag when getAllTorrents resets it', async () => { mockLogin(); const client = makeClient(); await client.login(); client.fallbackThisCycle = true; // After reset, sync should be attempted mockSync(0, { rid: 1, full_update: true, torrents: { hash01: { name: 'ResetWorks', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 } } }); // Simulate the reset that getAllTorrents performs client.fallbackThisCycle = false; const torrents = await client.getActiveDownloads(); expect(torrents[0].title).toBe('ResetWorks'); expect(client.fallbackThisCycle).toBe(false); }); });