Framework:
- Vitest v4 as test runner (fast ESM/CJS support, V8 coverage built-in)
- supertest for integration tests against createApp() factory
- nock for HTTP interception (works with CJS require('axios'), unlike vi.mock)
New files:
- vitest.config.js — test config: node env, isolate, V8 coverage, per-file thresholds
- tests/setup.js — isolated DATA_DIR per worker, SKIP_RATE_LIMIT, console suppression
- tests/README.md — approach, structure, design decisions
- server/app.js — testable Express factory (extracted from index.js side-effects)
Unit tests (91 tests):
- tests/unit/sanitizeError.test.js — secret redaction: apikey, token, bearer, basic-auth URLs
- tests/unit/config.test.js — JSON array + legacy single-instance config parsing
- tests/unit/requireAuth.test.js — valid/invalid/tampered cookies, schema validation
- tests/unit/verifyCsrf.test.js — double-submit pattern, timing-safe compare, safe methods
- tests/unit/qbittorrent.test.js — formatBytes, formatEta, mapTorrentToDownload state map
- tests/unit/tokenStore.test.js — store/get/clear lifecycle, TTL expiry, atomic disk write
Integration tests (24 tests):
- tests/integration/health.test.js — /health and /ready endpoints
- tests/integration/auth.test.js — full login/logout/me/csrf flows, input validation,
cookie attributes, no token leakage, Emby mock via nock
Production code changes (minimal, no behaviour change):
- server/routes/auth.js: EMBY_URL captured at request-time (not module load) for testability
- server/routes/auth.js: loginLimiter max → Number.MAX_SAFE_INTEGER when SKIP_RATE_LIMIT set
- server/utils/sanitizeError.js: fix HEADER_PATTERN to redact full line (not just first token)
CI:
- .gitea/workflows/ci.yml: add parallel 'test' job (npm run test:coverage, artifact upload)
- package.json: add test/test:watch/test:coverage/test:ui scripts
- .gitignore: add coverage/
112 lines
4.2 KiB
JavaScript
112 lines
4.2 KiB
JavaScript
/**
|
|
* 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';
|
|
|
|
// 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
|
|
};
|
|
}
|
|
|
|
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/');
|
|
});
|
|
});
|