Some checks failed
- Convert RTorrentClient.test.js to use vi.mock() instead of jest.mock() - Convert QBittorrentClient.test.js to use vi.mock() instead of jest.mock() - Convert SABnzbdClient.test.js to use vi.mock() instead of jest.mock() - Convert TransmissionClient.test.js to use vi.mock() instead of jest.mock() - Convert downloadClients.test.js to use vi.mock() instead of jest.mock() - Convert integration/downloadClients.test.js to use vi.mock() instead of jest.mock() - Fix legacy qbittorrent.test.js to import QBittorrentClient from new location - Add getRtorrentInstances mock to downloadClients.test.js - Add RTORRENT_INSTANCES to integration test environment variables
371 lines
12 KiB
JavaScript
371 lines
12 KiB
JavaScript
// 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.getTorrents();
|
|
expect(torrents).toHaveLength(1);
|
|
expect(torrents[0].name).toBe('Test1');
|
|
expect(torrents[0].instanceId).toBe('test-qbt');
|
|
expect(torrents[0].hash).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.getTorrents();
|
|
|
|
// Second call — delta
|
|
mockSync(1, {
|
|
rid: 2,
|
|
full_update: false,
|
|
torrents: {
|
|
hash01: { dlspeed: 200 }
|
|
}
|
|
});
|
|
|
|
const torrents = await client.getTorrents();
|
|
expect(torrents).toHaveLength(1);
|
|
expect(torrents[0].dlspeed).toBe(200);
|
|
expect(torrents[0].name).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.getTorrents();
|
|
|
|
// 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.getTorrents();
|
|
expect(torrents).toHaveLength(1);
|
|
expect(torrents[0].name).toBe('Test2');
|
|
expect(torrents[0].hash).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.getTorrents();
|
|
|
|
mockSync(1, {
|
|
rid: 2,
|
|
full_update: false,
|
|
torrents_removed: ['hash01']
|
|
});
|
|
|
|
const torrents = await client.getTorrents();
|
|
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.getTorrents();
|
|
expect(torrents).toHaveLength(1);
|
|
expect(torrents[0].name).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.getTorrents();
|
|
expect(torrents).toHaveLength(1);
|
|
expect(torrents[0].name).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.getTorrents();
|
|
expect(torrents).toHaveLength(1);
|
|
expect(torrents[0].name).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.getTorrents();
|
|
expect(torrents[0].completed).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.getTorrents();
|
|
expect(torrents[0].name).toBe('ResetWorks');
|
|
expect(client.fallbackThisCycle).toBe(false);
|
|
});
|
|
});
|