Files
Firelink/src/utils/downloads.test.ts
T
2026-08-02 10:50:30 +03:30

212 lines
8.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { DownloadItem } from '../bindings/DownloadItem';
import {
downloadFileNamesMatch,
downloadFileNameWithSuffix,
downloadMediaKindsMatch,
MAX_DOWNLOAD_FILENAME_BYTES,
canonicalizeDownloadFileName,
isValidTorrentExcludeTrackerList,
isValidTorrentTrackerList,
normalizeTorrentEncryptionPolicy,
normalizeTorrentMaxOpenFiles,
normalizeTorrentPrioritizePiece,
normalizeTorrentTrackerInterval,
normalizeTorrentTrackerTimeout,
redactDownloadForPersistence,
resolveDownloadConnections
} from './downloads';
const item = (status: DownloadItem['status']): DownloadItem => ({
id: 'download-1',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
status,
category: 'Other',
dateAdded: '2026-07-15T00:00:00.000Z',
downloadedBytes: 1024,
totalBytes: 4096,
totalIsEstimate: false
});
describe('download persistence progress snapshots', () => {
it('does not write active byte counters on every progress event', () => {
const persisted = redactDownloadForPersistence(item('downloading'));
expect(persisted.downloadedBytes).toBeUndefined();
expect(persisted.totalBytes).toBeUndefined();
expect(persisted.totalIsEstimate).toBeUndefined();
});
it('keeps byte counters for paused snapshots', () => {
const persisted = redactDownloadForPersistence(item('paused'));
expect(persisted.downloadedBytes).toBe(1024);
expect(persisted.totalBytes).toBe(4096);
expect(persisted.totalIsEstimate).toBe(false);
});
it.each(['queued', 'staged', 'retrying', 'processing'] as const)(
'keeps byte counters for %s snapshots',
(status) => {
const persisted = redactDownloadForPersistence(item(status));
expect(persisted.downloadedBytes).toBe(1024);
expect(persisted.totalBytes).toBe(4096);
expect(persisted.totalIsEstimate).toBe(false);
}
);
});
describe('Torrent tracker input validation', () => {
it('accepts supported trackers separated by lines or commas', () => {
expect(isValidTorrentTrackerList(
' https://tracker.example/announce\nudp://tracker.example:6969/announce '
)).toBe(true);
expect(isValidTorrentTrackerList('https://tracker.example/announce,https://tracker.example/announce')).toBe(true);
});
it('rejects unsupported, credential-bearing, empty, and oversized entries', () => {
expect(isValidTorrentTrackerList('ftp://tracker.example/announce')).toBe(false);
expect(isValidTorrentTrackerList('https://user:pass@tracker.example/announce')).toBe(false);
expect(isValidTorrentTrackerList('https://tracker.example/announce,')).toBe(false);
expect(isValidTorrentTrackerList(Array.from({ length: 65 }, (_, index) => `https://tracker${index}.example/announce`).join('\n'))).toBe(false);
});
it('accepts wildcard exclusions but rejects ambiguous mixtures', () => {
expect(isValidTorrentExcludeTrackerList('*')).toBe(true);
expect(isValidTorrentExcludeTrackerList('https://tracker.example/announce\nudp://tracker.example:6969/announce')).toBe(true);
expect(isValidTorrentExcludeTrackerList('*,https://tracker.example/announce')).toBe(false);
expect(isValidTorrentExcludeTrackerList('https://tracker.example/announce,*')).toBe(false);
expect(isValidTorrentExcludeTrackerList('https://user:pass@tracker.example/announce')).toBe(false);
});
});
describe('Torrent piece priority validation', () => {
it('normalizes head and tail preview policies', () => {
expect(normalizeTorrentPrioritizePiece(' tail = 64k, HEAD ')).toBe('head,tail=64K');
expect(normalizeTorrentPrioritizePiece('head=1m,tail=1024M')).toBe('head=1M,tail=1024M');
expect(normalizeTorrentPrioritizePiece('')).toBeNull();
});
it('rejects duplicate, unsupported, malformed, and oversized policies', () => {
for (const value of ['head,head', 'middle', 'head=0K', 'tail=1G', 'head=1K,', 'head=1025M']) {
expect(normalizeTorrentPrioritizePiece(value)).toBeNull();
}
});
});
describe('Torrent encryption policy validation', () => {
it('accepts only the canonical policy states', () => {
expect(normalizeTorrentEncryptionPolicy('disabled')).toBe('disabled');
expect(normalizeTorrentEncryptionPolicy('require-crypto')).toBe('require-crypto');
expect(normalizeTorrentEncryptionPolicy('force-encryption')).toBe('force-encryption');
});
it('clears unknown or malformed persisted values', () => {
expect(normalizeTorrentEncryptionPolicy(undefined)).toBeUndefined();
expect(normalizeTorrentEncryptionPolicy('arc4')).toBeUndefined();
expect(normalizeTorrentEncryptionPolicy(true)).toBeUndefined();
});
});
describe('Torrent tracker timing validation', () => {
it('accepts bounded timeout values and an automatic interval', () => {
expect(normalizeTorrentTrackerTimeout('1')).toBe(1);
expect(normalizeTorrentTrackerTimeout(604800)).toBe(604800);
expect(normalizeTorrentTrackerInterval('0')).toBe(0);
expect(normalizeTorrentTrackerInterval(604800)).toBe(604800);
});
it('rejects zero timeouts and out-of-range timing values', () => {
expect(normalizeTorrentTrackerTimeout('0')).toBeUndefined();
expect(normalizeTorrentTrackerTimeout(604801)).toBeUndefined();
expect(normalizeTorrentTrackerInterval(-1)).toBeUndefined();
expect(normalizeTorrentTrackerInterval(604801)).toBeUndefined();
expect(normalizeTorrentTrackerTimeout('1.5')).toBeUndefined();
});
});
describe('Torrent open-file limit validation', () => {
it('accepts bounded integer limits', () => {
expect(normalizeTorrentMaxOpenFiles(1)).toBe(1);
expect(normalizeTorrentMaxOpenFiles(4096)).toBe(4096);
});
it('rejects zero, fractional, and oversized limits', () => {
expect(normalizeTorrentMaxOpenFiles(0)).toBeUndefined();
expect(normalizeTorrentMaxOpenFiles('1.5')).toBeUndefined();
expect(normalizeTorrentMaxOpenFiles(4097)).toBeUndefined();
});
});
describe('download connection resolution', () => {
it('uses a clamped fallback for legacy rows without a saved value', () => {
expect(resolveDownloadConnections(undefined, 8)).toBe(8);
expect(resolveDownloadConnections(undefined, 0)).toBe(1);
expect(resolveDownloadConnections(undefined, Number.NaN)).toBe(16);
});
it('clamps malformed saved values before dispatch', () => {
expect(resolveDownloadConnections(0, 8)).toBe(1);
expect(resolveDownloadConnections(17, 8)).toBe(16);
expect(resolveDownloadConnections(Number.NaN, 8)).toBe(8);
});
});
describe('download filename matching', () => {
it('matches frontend filenames to the backend Windows device-name canonicalization', () => {
expect(canonicalizeDownloadFileName('CON.txt')).toBe('CON-.txt');
expect(canonicalizeDownloadFileName('CON .txt')).toBe('CON -.txt');
expect(canonicalizeDownloadFileName('com1.archive.zip')).toBe('com1.archive-.zip');
expect(downloadFileNamesMatch('CON-.txt', 'CON.txt')).toBe(true);
expect(downloadFileNamesMatch('console.txt', 'CON.txt')).toBe(false);
});
it('truncates long names by UTF-8 bytes while preserving the extension', () => {
const filename = canonicalizeDownloadFileName(`${'title '.repeat(100)}.mp4`);
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(filename.endsWith('.mp4')).toBe(true);
expect(filename).toContain('…');
});
it('does not split a multibyte character at the filesystem boundary', () => {
const filename = canonicalizeDownloadFileName(`${'😀'.repeat(100)}.mkv`);
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(filename.endsWith('.mkv')).toBe(true);
expect([...filename].every(character => character !== '\uFFFD')).toBe(true);
});
it('keeps alternate names unique and bounded after long-name truncation', () => {
const filename = downloadFileNameWithSuffix(`${'title '.repeat(100)}.mp4`, ' (1)');
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(filename.endsWith(' (1).mp4')).toBe(true);
});
it('matches case and path spelling while preserving the actual filename', () => {
expect(downloadFileNamesMatch(
'Media\\Example.Show.S01E01.MKV',
'example.show.s01e01.mkv'
)).toBe(true);
});
it('does not collapse distinct extensions or names', () => {
expect(downloadFileNamesMatch('example.zip', 'example.tar')).toBe(false);
expect(downloadFileNamesMatch('example-1.zip', 'example.zip')).toBe(false);
});
it('does not auto-match weak metadata fallback names', () => {
expect(downloadFileNamesMatch('download', 'download')).toBe(false);
expect(downloadFileNamesMatch('identifier', 'IDENTIFIER')).toBe(false);
expect(downloadFileNamesMatch('real-file.bin', 'real-file.bin')).toBe(true);
});
it('treats omitted media flags as ordinary downloads', () => {
expect(downloadMediaKindsMatch(undefined, false)).toBe(true);
expect(downloadMediaKindsMatch(undefined, true)).toBe(false);
});
});