mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
1b0a80052d
Always use qBittorrent's content_path as the canonical downloadPath and expose savePath on DownloadInfo instead of reconstructing paths from save_path + basename. Add path-waiting logic to the monitor: track consecutive pathWaitCount polls, re-queue the monitor with exponential-ish backoff while content_path remains outside save_path (to handle TempPathEnabled races), and give up after a configurable max attempts. Extend the MonitorDownload payload and JobQueue APIs to carry pathWaitCount. Organize-files processor now attempts to refresh the stored downloadPath from the download client and updates downloadHistory if the client reports a different path (applying path mapping). Update tests to reflect the new behavior and expectations.
1221 lines
46 KiB
TypeScript
1221 lines
46 KiB
TypeScript
/**
|
|
* Component: qBittorrent Integration Service Tests
|
|
* Documentation: documentation/phase3/qbittorrent.md
|
|
*/
|
|
|
|
import path from 'path';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { QBittorrentService, getQBittorrentService, invalidateQBittorrentService } from '@/lib/integrations/qbittorrent.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());
|
|
const configServiceMock = vi.hoisted(() => ({
|
|
getMany: vi.fn(),
|
|
get: vi.fn(),
|
|
}));
|
|
|
|
// Mock for DownloadClientManager
|
|
const downloadClientManagerMock = vi.hoisted(() => ({
|
|
getClientForProtocol: vi.fn(),
|
|
getAllClients: vi.fn(),
|
|
hasClientForProtocol: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('axios', () => ({
|
|
default: axiosMock,
|
|
...axiosMock,
|
|
}));
|
|
|
|
vi.mock('parse-torrent', () => ({
|
|
default: parseTorrentMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/config.service', () => ({
|
|
getConfigService: vi.fn(async () => configServiceMock),
|
|
}));
|
|
|
|
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
|
getDownloadClientManager: () => downloadClientManagerMock,
|
|
invalidateDownloadClientManager: vi.fn(),
|
|
}));
|
|
|
|
describe('QBittorrentService', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
clientMock.get.mockReset();
|
|
clientMock.post.mockReset();
|
|
axiosMock.get.mockReset();
|
|
axiosMock.post.mockReset();
|
|
parseTorrentMock.mockReset();
|
|
configServiceMock.getMany.mockReset();
|
|
configServiceMock.get.mockReset();
|
|
downloadClientManagerMock.getClientForProtocol.mockReset();
|
|
downloadClientManagerMock.getAllClients.mockReset();
|
|
downloadClientManagerMock.hasClientForProtocol.mockReset();
|
|
invalidateQBittorrentService();
|
|
});
|
|
|
|
it('maps download progress from torrent info', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0.42,
|
|
downloaded: 420,
|
|
size: 1000,
|
|
dlspeed: 50,
|
|
eta: 120,
|
|
state: 'pausedDL',
|
|
} as any);
|
|
|
|
expect(progress.percent).toBe(42);
|
|
expect(progress.bytesDownloaded).toBe(420);
|
|
expect(progress.bytesTotal).toBe(1000);
|
|
expect(progress.speed).toBe(50);
|
|
expect(progress.eta).toBe(120);
|
|
expect(progress.state).toBe('paused');
|
|
});
|
|
|
|
it('extracts info hash from magnet links', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const hash = (service as any).extractHashFromMagnet(
|
|
'magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567'
|
|
);
|
|
|
|
expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
|
|
expect((service as any).extractHashFromMagnet('magnet:?xt=urn:btih:')).toBeNull();
|
|
});
|
|
|
|
it('maps allocating state to downloading', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0.1,
|
|
downloaded: 100,
|
|
size: 1000,
|
|
dlspeed: 0,
|
|
eta: 0,
|
|
state: 'allocating',
|
|
} as any);
|
|
|
|
expect(progress.state).toBe('downloading');
|
|
});
|
|
|
|
describe('mapState - forced states (Force Resume in qBittorrent UI)', () => {
|
|
it('maps forcedDL to downloading', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0.5, downloaded: 500, size: 1000, dlspeed: 100, eta: 50, state: 'forcedDL',
|
|
} as any);
|
|
expect(progress.state).toBe('downloading');
|
|
});
|
|
|
|
it('maps forcedUP to completed', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'forcedUP',
|
|
} as any);
|
|
expect(progress.state).toBe('completed');
|
|
});
|
|
});
|
|
|
|
describe('mapState - metadata fetching states', () => {
|
|
it('maps metaDL to downloading', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0, downloaded: 0, size: 0, dlspeed: 0, eta: 0, state: 'metaDL',
|
|
} as any);
|
|
expect(progress.state).toBe('downloading');
|
|
});
|
|
|
|
it('maps forcedMetaDL to downloading', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0, downloaded: 0, size: 0, dlspeed: 0, eta: 0, state: 'forcedMetaDL',
|
|
} as any);
|
|
expect(progress.state).toBe('downloading');
|
|
});
|
|
});
|
|
|
|
describe('mapState - qBittorrent v5.x stopped states', () => {
|
|
it('maps stoppedDL to paused', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0.3, downloaded: 300, size: 1000, dlspeed: 0, eta: 0, state: 'stoppedDL',
|
|
} as any);
|
|
expect(progress.state).toBe('paused');
|
|
});
|
|
|
|
it('maps stoppedUP to completed (download finished, stopped on upload side)', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'stoppedUP',
|
|
} as any);
|
|
expect(progress.state).toBe('completed');
|
|
});
|
|
});
|
|
|
|
describe('mapState - other states', () => {
|
|
it('maps checkingResumeData to checking', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0, downloaded: 0, size: 1000, dlspeed: 0, eta: 0, state: 'checkingResumeData',
|
|
} as any);
|
|
expect(progress.state).toBe('checking');
|
|
});
|
|
|
|
it('maps moving to downloading', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'moving',
|
|
} as any);
|
|
expect(progress.state).toBe('downloading');
|
|
});
|
|
});
|
|
|
|
describe('mapState - pausedUP/stoppedUP as completion states', () => {
|
|
it('maps pausedUP to completed (download finished, paused on upload side)', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0.5, downloaded: 0, size: 0, dlspeed: 0, eta: 0, state: 'pausedUP',
|
|
} as any);
|
|
expect(progress.state).toBe('completed');
|
|
});
|
|
|
|
it('maps pausedDL to paused (download not finished)', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0.3, downloaded: 300, size: 1000, dlspeed: 0, eta: 0, state: 'pausedDL',
|
|
} as any);
|
|
expect(progress.state).toBe('paused');
|
|
});
|
|
});
|
|
|
|
describe('mapStateToDownloadStatus - forced and new states via getDownload', () => {
|
|
it('maps forcedUP to seeding status (triggers completion in monitor)', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=forced';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
|
dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500,
|
|
eta: 0, state: 'forcedUP', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads', content_path: '/downloads/Audiobook',
|
|
completion_on: 1700000000, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('seeding');
|
|
});
|
|
|
|
it('maps forcedDL to downloading status', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=forced';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.5,
|
|
dlspeed: 1000, upspeed: 0, downloaded: 500, uploaded: 0,
|
|
eta: 500, state: 'forcedDL', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads', completion_on: 0, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('downloading');
|
|
});
|
|
|
|
it('maps stoppedUP to seeding status (qBittorrent v5.x, triggers completion)', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=stopped';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
|
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 200,
|
|
eta: 0, state: 'stoppedUP', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads', completion_on: 1700000000, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('seeding');
|
|
});
|
|
|
|
it('maps pausedUP to seeding status (download finished, paused on upload side)', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=pausedup';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'd5d767f07e5d9027f7f9d9b50b877386dc92b177', name: 'Audiobook', size: 0, progress: 0.5,
|
|
dlspeed: 0, upspeed: 0, downloaded: 0, uploaded: 0,
|
|
eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
|
|
save_path: '/data/torrents/readmeabook', content_path: '/data/torrents/readmeabook/Audiobook',
|
|
completion_on: 1769135244, added_on: 1769135108,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('d5d767f07e5d9027f7f9d9b50b877386dc92b177');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('seeding');
|
|
});
|
|
|
|
it('maps stoppedDL to paused status (qBittorrent v5.x)', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=stopped';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.3,
|
|
dlspeed: 0, upspeed: 0, downloaded: 300, uploaded: 0,
|
|
eta: 0, state: 'stoppedDL', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads', completion_on: 0, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('paused');
|
|
});
|
|
|
|
it('maps metaDL to downloading status', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=meta';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 0, progress: 0,
|
|
dlspeed: 0, upspeed: 0, downloaded: 0, uploaded: 0,
|
|
eta: 0, state: 'metaDL', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads', completion_on: 0, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('downloading');
|
|
});
|
|
|
|
it('maps checkingResumeData to checking status', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=resume';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0,
|
|
dlspeed: 0, upspeed: 0, downloaded: 0, uploaded: 0,
|
|
eta: 0, state: 'checkingResumeData', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads', completion_on: 0, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('checking');
|
|
});
|
|
});
|
|
|
|
describe('downloadPath resolution', () => {
|
|
describe('normal operation (content_path under save_path)', () => {
|
|
it('uses content_path directly for seeding multi-file torrent', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=normal-multi';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
|
dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500,
|
|
eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads/', content_path: '/downloads/Audiobook',
|
|
completion_on: 1700000000, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('seeding');
|
|
expect(info!.downloadPath).toBe('/downloads/Audiobook');
|
|
expect(info!.savePath).toBe('/downloads/');
|
|
});
|
|
|
|
it('uses content_path directly for single-file torrent in folder', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=normal-single-folder';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook Name', size: 3700000000, progress: 1.0,
|
|
dlspeed: 0, upspeed: 0, downloaded: 3700000000, uploaded: 100000,
|
|
eta: 0, state: 'stalledUP', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads/books/',
|
|
content_path: '/downloads/books/Audiobook Folder/Audiobook.m4b',
|
|
completion_on: 1700000000, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info!.status).toBe('seeding');
|
|
// Must preserve the full path including intermediate folder
|
|
expect(info!.downloadPath).toBe('/downloads/books/Audiobook Folder/Audiobook.m4b');
|
|
expect(info!.savePath).toBe('/downloads/books/');
|
|
});
|
|
|
|
it('uses content_path directly when torrent name differs from folder name', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=name-mismatch';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123',
|
|
name: 'Harry Potter [Full-Cast] (aka Philosophers Stone) - J.K. Rowling',
|
|
size: 3006477107, progress: 1.0,
|
|
dlspeed: 0, upspeed: 0, downloaded: 3006477107, uploaded: 500000,
|
|
eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads/books/',
|
|
content_path: '/downloads/books/Harry Potter (Full-Cast Edition) EAC3 6ch - J.K. Rowling',
|
|
completion_on: 1700000000, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info!.status).toBe('seeding');
|
|
// Must use content_path (real folder name), NOT torrent.name
|
|
expect(info!.downloadPath).toBe(
|
|
'/downloads/books/Harry Potter (Full-Cast Edition) EAC3 6ch - J.K. Rowling'
|
|
);
|
|
expect(info!.downloadPath).not.toContain('[Full-Cast]');
|
|
expect(info!.savePath).toBe('/downloads/books/');
|
|
});
|
|
|
|
it('uses content_path directly for all seeding states (pausedUP, stalledUP, forcedUP, queuedUP, stoppedUP)', async () => {
|
|
const seedingStates = ['pausedUP', 'stalledUP', 'forcedUP', 'queuedUP', 'stoppedUP'];
|
|
|
|
for (const state of seedingStates) {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = `SID=state-${state}`;
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
|
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 100,
|
|
eta: 0, state, category: 'readmeabook', tags: '',
|
|
save_path: '/downloads/', content_path: '/downloads/Audiobook',
|
|
completion_on: 1700000000, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info!.status).toBe('seeding');
|
|
expect(info!.downloadPath).toBe('/downloads/Audiobook');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('TempPathEnabled (content_path outside save_path)', () => {
|
|
it('passes through content_path as-is even when pointing to temp dir (monitor handles wait)', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=temppath';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
|
dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500,
|
|
eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads/', content_path: '/incomplete/Audiobook',
|
|
completion_on: 1700000000, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info!.status).toBe('seeding');
|
|
// content_path is always used directly — monitor detects temp path via savePath
|
|
expect(info!.downloadPath).toBe('/incomplete/Audiobook');
|
|
expect(info!.savePath).toBe('/downloads/');
|
|
});
|
|
|
|
it('exposes savePath so monitor can detect temp path for pausedUP', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=pausedup-temp';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
|
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 0,
|
|
eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
|
|
save_path: '/data/torrents/readmeabook/', content_path: '/tmp/incomplete/Audiobook',
|
|
completion_on: 1700000000, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info!.status).toBe('seeding');
|
|
// content_path is always used directly — no reconstruction
|
|
expect(info!.downloadPath).toBe('/tmp/incomplete/Audiobook');
|
|
expect(info!.savePath).toBe('/data/torrents/readmeabook/');
|
|
});
|
|
});
|
|
|
|
describe('downloading torrents', () => {
|
|
it('uses content_path for actively downloading torrents', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=downloading';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.5,
|
|
dlspeed: 5000, upspeed: 0, downloaded: 500, uploaded: 0,
|
|
eta: 100, state: 'downloading', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads/', content_path: '/incomplete/Audiobook',
|
|
completion_on: 0, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info!.status).toBe('downloading');
|
|
// During download, content_path is used as-is (points to where files currently are)
|
|
expect(info!.downloadPath).toBe('/incomplete/Audiobook');
|
|
});
|
|
|
|
it('falls back to save_path + name when content_path is empty', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=nocontent';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.3,
|
|
dlspeed: 1000, upspeed: 0, downloaded: 300, uploaded: 0,
|
|
eta: 700, state: 'downloading', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads/', content_path: '',
|
|
completion_on: 0, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info!.status).toBe('downloading');
|
|
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
|
|
});
|
|
});
|
|
|
|
describe('empty content_path fallback', () => {
|
|
it('falls back to save_path + name for finished torrents with no content_path', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=nocontent-finished';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
|
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 0,
|
|
eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads/', content_path: '',
|
|
completion_on: 1700000000, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info!.status).toBe('seeding');
|
|
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
|
|
});
|
|
});
|
|
});
|
|
|
|
it('authenticates and stores a session cookie', async () => {
|
|
axiosMock.post.mockResolvedValue({
|
|
status: 200,
|
|
statusText: 'OK',
|
|
data: 'Ok.',
|
|
headers: { 'set-cookie': ['SID=abc; Path=/;'] },
|
|
});
|
|
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
await service.login();
|
|
|
|
expect((service as any).cookie).toBe('SID=abc');
|
|
});
|
|
|
|
it('throws when login response lacks a cookie', async () => {
|
|
axiosMock.post.mockResolvedValue({
|
|
status: 200,
|
|
statusText: 'OK',
|
|
data: 'Ok.',
|
|
headers: {},
|
|
});
|
|
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
|
|
await expect(service.login()).rejects.toThrow('Failed to authenticate with qBittorrent');
|
|
});
|
|
|
|
it('rejects empty torrent URLs', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
|
|
await expect(service.addTorrent('')).rejects.toThrow('Invalid download URL');
|
|
});
|
|
|
|
it('skips adding duplicate magnet links', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=dup';
|
|
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
|
vi.spyOn(service as any, 'getTorrent').mockResolvedValue({ hash: 'existing' });
|
|
|
|
const hash = await service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567');
|
|
|
|
expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
|
|
expect(clientMock.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('adds magnet links when not already present', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=add';
|
|
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
|
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
|
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
|
|
|
const hash = await service.addTorrent(
|
|
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
|
{ tags: ['tag1', 'tag2'] }
|
|
);
|
|
|
|
expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
|
|
expect(clientMock.post).toHaveBeenCalledWith(
|
|
'/torrents/add',
|
|
expect.any(URLSearchParams),
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('throws when magnet link is invalid', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=badmagnet';
|
|
|
|
await expect(
|
|
(service as any).addMagnetLink('magnet:?xt=urn:btih:', 'readmeabook')
|
|
).rejects.toThrow('Invalid magnet link');
|
|
});
|
|
|
|
it('throws when qBittorrent rejects magnet uploads', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=rejected';
|
|
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
|
clientMock.post.mockResolvedValue({ data: 'Nope' });
|
|
|
|
await expect(
|
|
(service as any).addMagnetLink(
|
|
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
|
'readmeabook'
|
|
)
|
|
).rejects.toThrow('qBittorrent rejected magnet link');
|
|
});
|
|
|
|
it('re-authenticates after a 403 and retries adding torrents', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=old';
|
|
|
|
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
|
const loginSpy = vi.spyOn(service, 'login').mockResolvedValue();
|
|
const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink')
|
|
.mockRejectedValueOnce({ isAxiosError: true, response: { status: 403 } })
|
|
.mockResolvedValueOnce('rehash');
|
|
|
|
const hash = await service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567');
|
|
|
|
expect(hash).toBe('rehash');
|
|
expect(loginSpy).toHaveBeenCalledTimes(1);
|
|
expect(addMagnetSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('follows redirect to magnet link when downloading torrent files', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=redir';
|
|
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
|
const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink').mockResolvedValue('redirect-hash');
|
|
|
|
axiosMock.get.mockRejectedValueOnce({
|
|
isAxiosError: true,
|
|
response: { status: 302, headers: { location: 'magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01' } },
|
|
});
|
|
|
|
const hash = await service.addTorrent('http://example.com/file.torrent');
|
|
|
|
expect(hash).toBe('redirect-hash');
|
|
expect(addMagnetSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('treats magnet response bodies as magnet links', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=body';
|
|
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
|
const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink').mockResolvedValue('body-hash');
|
|
|
|
axiosMock.get.mockResolvedValueOnce({
|
|
data: Buffer.from('magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01'),
|
|
});
|
|
|
|
const hash = await service.addTorrent('http://example.com/file.torrent');
|
|
|
|
expect(hash).toBe('body-hash');
|
|
expect(addMagnetSpy).toHaveBeenCalled();
|
|
expect(parseTorrentMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('adds torrent files after parsing successfully', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=ok';
|
|
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
|
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
|
|
|
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
|
|
parseTorrentMock.mockResolvedValueOnce({ infoHash: 'hash-1', name: 'Book' });
|
|
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
|
|
|
const hash = await service.addTorrent('http://example.com/file.torrent');
|
|
|
|
expect(hash).toBe('hash-1');
|
|
expect(clientMock.post).toHaveBeenCalledWith(
|
|
'/torrents/add',
|
|
expect.any(Object),
|
|
expect.objectContaining({ maxBodyLength: Infinity })
|
|
);
|
|
});
|
|
|
|
it('throws for invalid redirect locations when fetching torrents', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
|
|
axiosMock.get.mockRejectedValueOnce({
|
|
isAxiosError: true,
|
|
response: { status: 302, headers: { location: 'ftp://bad' } },
|
|
message: 'redirect',
|
|
});
|
|
|
|
await expect(
|
|
(service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
|
|
).rejects.toThrow('Invalid redirect location');
|
|
});
|
|
|
|
it('throws when torrent file parsing fails directly', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
|
|
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
|
|
parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent'));
|
|
|
|
await expect(
|
|
(service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
|
|
).rejects.toThrow('Invalid .torrent file - failed to parse');
|
|
});
|
|
|
|
it('throws when torrent file has no info hash', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
|
|
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
|
|
parseTorrentMock.mockResolvedValueOnce({ infoHash: null });
|
|
|
|
await expect(
|
|
(service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
|
|
).rejects.toThrow('Failed to extract info_hash');
|
|
});
|
|
|
|
it('throws when qBittorrent rejects torrent file uploads', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=reject';
|
|
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
|
|
|
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
|
|
parseTorrentMock.mockResolvedValueOnce({ infoHash: 'hash-2', name: 'Book' });
|
|
clientMock.post.mockResolvedValue({ data: 'Nope' });
|
|
|
|
await expect(
|
|
(service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
|
|
).rejects.toThrow('qBittorrent rejected .torrent file');
|
|
});
|
|
|
|
it('throws when torrent parsing fails', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=parse';
|
|
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
|
|
|
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('not-a-torrent') });
|
|
parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent'));
|
|
|
|
await expect(service.addTorrent('http://example.com/file.torrent')).rejects.toThrow(
|
|
'Failed to add torrent to qBittorrent'
|
|
);
|
|
});
|
|
|
|
it('creates categories when missing', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads');
|
|
(service as any).cookie = 'SID=newcat';
|
|
clientMock.get.mockResolvedValue({ data: {} });
|
|
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
|
|
|
await (service as any).ensureCategory('readmeabook');
|
|
|
|
expect(clientMock.post).toHaveBeenCalledWith(
|
|
'/torrents/createCategory',
|
|
expect.any(URLSearchParams),
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('does not throw when ensuring categories fails', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=catfail';
|
|
clientMock.get.mockRejectedValueOnce({
|
|
isAxiosError: true,
|
|
response: { status: 500 },
|
|
});
|
|
|
|
await expect((service as any).ensureCategory('readmeabook')).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('updates category when save path mismatches', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads');
|
|
(service as any).cookie = 'SID=cat';
|
|
clientMock.get.mockResolvedValue({
|
|
data: {
|
|
readmeabook: { savePath: '/old' },
|
|
},
|
|
});
|
|
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
|
|
|
await (service as any).ensureCategory('readmeabook');
|
|
|
|
expect(clientMock.post).toHaveBeenCalledWith(
|
|
'/torrents/editCategory',
|
|
expect.any(URLSearchParams),
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('does not update category when save path matches', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads');
|
|
(service as any).cookie = 'SID=cat-ok';
|
|
clientMock.get.mockResolvedValue({
|
|
data: {
|
|
readmeabook: { savePath: '/downloads' },
|
|
},
|
|
});
|
|
|
|
await (service as any).ensureCategory('readmeabook');
|
|
|
|
expect(clientMock.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('applies reverse path mapping when creating category', async () => {
|
|
const service = new QBittorrentService(
|
|
'http://qb',
|
|
'user',
|
|
'pass',
|
|
'/downloads',
|
|
'readmeabook',
|
|
false,
|
|
{ enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' }
|
|
);
|
|
(service as any).cookie = 'SID=pathmap';
|
|
clientMock.get.mockResolvedValue({ data: {} }); // No existing categories
|
|
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
|
|
|
await (service as any).ensureCategory('readmeabook');
|
|
|
|
expect(clientMock.post).toHaveBeenCalledWith(
|
|
'/torrents/createCategory',
|
|
expect.any(URLSearchParams),
|
|
expect.any(Object)
|
|
);
|
|
|
|
// Verify the savePath was reverse transformed (local → remote)
|
|
const postCall = clientMock.post.mock.calls[0];
|
|
const params = postCall[1] as URLSearchParams;
|
|
expect(params.get('savePath')).toBe('F:\\Docker\\downloads');
|
|
});
|
|
|
|
it('applies reverse path mapping when updating category', async () => {
|
|
const service = new QBittorrentService(
|
|
'http://qb',
|
|
'user',
|
|
'pass',
|
|
'/downloads',
|
|
'readmeabook',
|
|
false,
|
|
{ enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' }
|
|
);
|
|
(service as any).cookie = 'SID=pathmap-update';
|
|
// Category exists with old path
|
|
clientMock.get.mockResolvedValue({
|
|
data: {
|
|
readmeabook: { savePath: 'F:\\OldPath' },
|
|
},
|
|
});
|
|
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
|
|
|
await (service as any).ensureCategory('readmeabook');
|
|
|
|
expect(clientMock.post).toHaveBeenCalledWith(
|
|
'/torrents/editCategory',
|
|
expect.any(URLSearchParams),
|
|
expect.any(Object)
|
|
);
|
|
|
|
// Verify the savePath was reverse transformed (local → remote)
|
|
const postCall = clientMock.post.mock.calls[0];
|
|
const params = postCall[1] as URLSearchParams;
|
|
expect(params.get('savePath')).toBe('F:\\Docker\\downloads');
|
|
});
|
|
|
|
it('does not update category when remote path already matches', async () => {
|
|
const service = new QBittorrentService(
|
|
'http://qb',
|
|
'user',
|
|
'pass',
|
|
'/downloads',
|
|
'readmeabook',
|
|
false,
|
|
{ enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' }
|
|
);
|
|
(service as any).cookie = 'SID=pathmap-match';
|
|
// Category already has the correct remote path
|
|
clientMock.get.mockResolvedValue({
|
|
data: {
|
|
readmeabook: { savePath: 'F:\\Docker\\downloads' },
|
|
},
|
|
});
|
|
|
|
await (service as any).ensureCategory('readmeabook');
|
|
|
|
// Should not call post since path already matches
|
|
expect(clientMock.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('pauses and resumes torrents', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=pause';
|
|
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
|
|
|
await service.pauseTorrent('hash-1');
|
|
await service.resumeTorrent('hash-1');
|
|
|
|
expect(clientMock.post).toHaveBeenCalledWith(
|
|
'/torrents/pause',
|
|
expect.any(URLSearchParams),
|
|
expect.any(Object)
|
|
);
|
|
expect(clientMock.post).toHaveBeenCalledWith(
|
|
'/torrents/resume',
|
|
expect.any(URLSearchParams),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('throws when torrent state updates fail', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=fail';
|
|
clientMock.post.mockRejectedValue(new Error('boom'));
|
|
|
|
await expect(service.pauseTorrent('hash-1')).rejects.toThrow('Failed to pause torrent');
|
|
await expect(service.resumeTorrent('hash-1')).rejects.toThrow('Failed to resume torrent');
|
|
await expect(service.deleteTorrent('hash-1', false)).rejects.toThrow('Failed to delete torrent');
|
|
await expect(service.setCategory('hash-1', 'books')).rejects.toThrow('Failed to set torrent category');
|
|
});
|
|
|
|
it('sets categories, deletes torrents, and fetches files', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=ops';
|
|
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
|
clientMock.get.mockResolvedValue({ data: [{ name: 'file1' }] });
|
|
|
|
await service.setCategory('hash-1', 'books');
|
|
await service.deleteTorrent('hash-1', true);
|
|
const files = await service.getFiles('hash-1');
|
|
|
|
expect(files).toEqual([{ name: 'file1' }]);
|
|
expect(clientMock.post).toHaveBeenCalledWith(
|
|
'/torrents/setCategory',
|
|
expect.any(URLSearchParams),
|
|
expect.any(Object)
|
|
);
|
|
expect(clientMock.post).toHaveBeenCalledWith(
|
|
'/torrents/delete',
|
|
expect.any(URLSearchParams),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('throws when fetching torrent files fails', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=files';
|
|
clientMock.get.mockRejectedValue(new Error('no files'));
|
|
|
|
await expect(service.getFiles('hash-1')).rejects.toThrow('Failed to get torrent files');
|
|
});
|
|
|
|
it('throws when torrent is not found', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=missing';
|
|
clientMock.get.mockResolvedValueOnce({ data: [] });
|
|
|
|
await expect(service.getTorrent('hash-404')).rejects.toThrow('Torrent hash-404 not found');
|
|
});
|
|
|
|
it('ignores unrelated torrents returned by RDTClient-like clients that ignore hash filter', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=rdtclient';
|
|
// RDTClient ignores the hashes param and returns all torrents
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [
|
|
{ hash: 'aaaa1111bbbb2222cccc3333dddd4444eeee5555', name: 'Other Book' },
|
|
{ hash: 'ffff6666aaaa7777bbbb8888cccc9999dddd0000', name: 'Another Book' },
|
|
],
|
|
});
|
|
|
|
await expect(
|
|
service.getTorrent('0f54898dc1b8e49d96e32827377f651ea6c935af')
|
|
).rejects.toThrow('Torrent 0f54898dc1b8e49d96e32827377f651ea6c935af not found');
|
|
});
|
|
|
|
it('finds the correct torrent when RDTClient returns all torrents including the match', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=rdtclient2';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [
|
|
{ hash: 'aaaa1111bbbb2222cccc3333dddd4444eeee5555', name: 'Other Book' },
|
|
{ hash: '0F54898DC1B8E49D96E32827377F651EA6C935AF', name: 'Target Book' },
|
|
],
|
|
});
|
|
|
|
const result = await service.getTorrent('0f54898dc1b8e49d96e32827377f651ea6c935af');
|
|
|
|
expect(result.name).toBe('Target Book');
|
|
});
|
|
|
|
it('returns error when getTorrents fails', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=list';
|
|
clientMock.get.mockRejectedValue(new Error('boom'));
|
|
|
|
await expect(service.getTorrents()).rejects.toThrow('Failed to get torrents from qBittorrent');
|
|
});
|
|
|
|
it('returns torrent lists with a category filter', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=list';
|
|
clientMock.get.mockResolvedValueOnce({ data: [{ hash: 'h1' }] });
|
|
|
|
const torrents = await service.getTorrents('books');
|
|
|
|
expect(torrents).toEqual([{ hash: 'h1' }]);
|
|
expect(clientMock.get).toHaveBeenCalledWith(
|
|
'/torrents/info',
|
|
expect.objectContaining({ params: { category: 'books' } })
|
|
);
|
|
});
|
|
|
|
it('returns unknown state for unrecognized torrent states', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0,
|
|
downloaded: 0,
|
|
size: 1,
|
|
dlspeed: 0,
|
|
eta: 0,
|
|
state: 'weird' as any,
|
|
} as any);
|
|
|
|
expect(progress.state).toBe('unknown');
|
|
});
|
|
|
|
it('throws specific errors for invalid credentials in testConnectionWithCredentials', async () => {
|
|
axiosMock.post.mockResolvedValueOnce({
|
|
status: 200,
|
|
statusText: 'OK',
|
|
data: 'Ok.',
|
|
headers: { 'set-cookie': ['SID=abc; Path=/;'] },
|
|
});
|
|
axiosMock.get.mockRejectedValueOnce({
|
|
isAxiosError: true,
|
|
response: { status: 401 },
|
|
config: { url: 'http://qb/api/v2/app/version' },
|
|
message: 'Unauthorized',
|
|
});
|
|
|
|
await expect(
|
|
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'bad')
|
|
).rejects.toThrow('Authentication failed');
|
|
});
|
|
|
|
it('returns version on successful credential test', async () => {
|
|
axiosMock.post.mockResolvedValueOnce({
|
|
status: 200,
|
|
statusText: 'OK',
|
|
data: 'Ok.',
|
|
headers: { 'set-cookie': ['SID=abc; Path=/;'] },
|
|
});
|
|
axiosMock.get.mockResolvedValueOnce({
|
|
data: 'v4.6.0',
|
|
headers: {},
|
|
});
|
|
|
|
const version = await QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass');
|
|
|
|
expect(version).toBe('4.6.0');
|
|
});
|
|
|
|
it('throws when test connection receives no cookies', async () => {
|
|
axiosMock.post.mockResolvedValueOnce({
|
|
status: 200,
|
|
statusText: 'OK',
|
|
data: 'Ok.',
|
|
headers: {},
|
|
});
|
|
|
|
await expect(
|
|
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
|
|
).rejects.toThrow('Failed to authenticate - no session cookie received');
|
|
});
|
|
|
|
it('throws SSL-specific errors for certificate failures', async () => {
|
|
axiosMock.post.mockRejectedValueOnce({
|
|
isAxiosError: true,
|
|
code: 'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
message: 'self signed',
|
|
config: { url: 'https://qb/api/v2/auth/login' },
|
|
});
|
|
|
|
await expect(
|
|
QBittorrentService.testConnectionWithCredentials('https://qb', 'user', 'pass', true)
|
|
).rejects.toThrow('SSL certificate verification failed');
|
|
});
|
|
|
|
it('throws when connection is refused', async () => {
|
|
axiosMock.post.mockRejectedValueOnce({
|
|
isAxiosError: true,
|
|
code: 'ECONNREFUSED',
|
|
message: 'refused',
|
|
config: { url: 'http://qb/api/v2/auth/login' },
|
|
});
|
|
|
|
await expect(
|
|
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
|
|
).rejects.toThrow('Connection refused');
|
|
});
|
|
|
|
it('throws when server returns 404', async () => {
|
|
axiosMock.post.mockRejectedValueOnce({
|
|
isAxiosError: true,
|
|
response: { status: 404 },
|
|
message: 'Not found',
|
|
config: { url: 'http://qb/api/v2/auth/login' },
|
|
});
|
|
|
|
await expect(
|
|
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
|
|
).rejects.toThrow('qBittorrent Web UI not found');
|
|
});
|
|
|
|
it('throws on qBittorrent server errors', async () => {
|
|
axiosMock.post.mockRejectedValueOnce({
|
|
isAxiosError: true,
|
|
response: { status: 503 },
|
|
message: 'Server error',
|
|
config: { url: 'http://qb/api/v2/auth/login' },
|
|
});
|
|
|
|
await expect(
|
|
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
|
|
).rejects.toThrow('qBittorrent server error');
|
|
});
|
|
|
|
it('throws when qBittorrent configuration is incomplete', async () => {
|
|
// Mock: no qBittorrent client configured
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null);
|
|
|
|
await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not configured');
|
|
});
|
|
|
|
it('returns a cached instance after successful initialization', async () => {
|
|
// Mock: qBittorrent client configured
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
url: 'http://qb',
|
|
username: 'user',
|
|
password: 'pass',
|
|
disableSSLVerify: false,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
configServiceMock.get.mockResolvedValue('/downloads');
|
|
|
|
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue({ success: true, message: 'Connected' });
|
|
|
|
const first = await getQBittorrentService();
|
|
const second = await getQBittorrentService();
|
|
|
|
expect(first).toBe(second);
|
|
// Should only call getClientForProtocol once (cached after first call)
|
|
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledTimes(1);
|
|
|
|
testConnectionSpy.mockRestore();
|
|
});
|
|
|
|
it('throws when connection test fails during service creation', async () => {
|
|
// Mock: qBittorrent client configured
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
url: 'http://qb',
|
|
username: 'user',
|
|
password: 'pass',
|
|
disableSSLVerify: false,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
configServiceMock.get.mockResolvedValue('/downloads');
|
|
|
|
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue({ success: false, message: 'qBittorrent connection test failed. Please check your configuration in admin settings.' });
|
|
|
|
await expect(getQBittorrentService()).rejects.toThrow('qBittorrent connection test failed');
|
|
|
|
testConnectionSpy.mockRestore();
|
|
});
|
|
|
|
it('returns false when connection test fails', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const loginSpy = vi.spyOn(service, 'login').mockRejectedValue(new Error('bad auth'));
|
|
|
|
const result = await service.testConnection();
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(loginSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns true when connection test succeeds', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const loginSpy = vi.spyOn(service, 'login').mockResolvedValue();
|
|
|
|
const result = await service.testConnection();
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(loginSpy).toHaveBeenCalled();
|
|
});
|
|
});
|