mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add Transmission/NZBGet and per-client paths and much more
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -102,12 +102,195 @@ describe('QBittorrentService', () => {
|
||||
size: 1000,
|
||||
dlspeed: 0,
|
||||
eta: 0,
|
||||
state: 'allocating' as any,
|
||||
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 paused', () => {
|
||||
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('paused');
|
||||
});
|
||||
});
|
||||
|
||||
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('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 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: 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('paused');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
it('authenticates and stores a session cookie', async () => {
|
||||
axiosMock.post.mockResolvedValue({
|
||||
status: 200,
|
||||
@@ -619,7 +802,7 @@ describe('QBittorrentService', () => {
|
||||
|
||||
const version = await QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass');
|
||||
|
||||
expect(version).toBe('v4.6.0');
|
||||
expect(version).toBe('4.6.0');
|
||||
});
|
||||
|
||||
it('throws when test connection receives no cookies', async () => {
|
||||
@@ -709,7 +892,7 @@ describe('QBittorrentService', () => {
|
||||
});
|
||||
configServiceMock.get.mockResolvedValue('/downloads');
|
||||
|
||||
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(true);
|
||||
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue({ success: true, message: 'Connected' });
|
||||
|
||||
const first = await getQBittorrentService();
|
||||
const second = await getQBittorrentService();
|
||||
@@ -736,7 +919,7 @@ describe('QBittorrentService', () => {
|
||||
});
|
||||
configServiceMock.get.mockResolvedValue('/downloads');
|
||||
|
||||
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(false);
|
||||
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');
|
||||
|
||||
@@ -747,9 +930,9 @@ describe('QBittorrentService', () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const loginSpy = vi.spyOn(service, 'login').mockRejectedValue(new Error('bad auth'));
|
||||
|
||||
const ok = await service.testConnection();
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(result.success).toBe(false);
|
||||
expect(loginSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -757,9 +940,9 @@ describe('QBittorrentService', () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const loginSpy = vi.spyOn(service, 'login').mockResolvedValue();
|
||||
|
||||
const ok = await service.testConnection();
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(result.success).toBe(true);
|
||||
expect(loginSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,13 @@ import { SABnzbdService, getSABnzbdService, invalidateSABnzbdService } from '@/l
|
||||
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
const axiosMock = vi.hoisted(() => ({
|
||||
create: vi.fn(() => clientMock),
|
||||
get: vi.fn(),
|
||||
isAxiosError: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
@@ -43,6 +46,8 @@ describe('SABnzbdService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clientMock.get.mockReset();
|
||||
clientMock.post.mockReset();
|
||||
axiosMock.get.mockReset();
|
||||
configServiceMock.get.mockReset();
|
||||
downloadClientManagerMock.getClientForProtocol.mockReset();
|
||||
downloadClientManagerMock.getAllClients.mockReset();
|
||||
@@ -56,7 +61,7 @@ describe('SABnzbdService', () => {
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('API key is required');
|
||||
expect(result.message).toContain('API key is required');
|
||||
expect(clientMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -69,7 +74,7 @@ describe('SABnzbdService', () => {
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid API key');
|
||||
expect(result.message).toContain('Invalid API key');
|
||||
expect(clientMock.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -82,7 +87,7 @@ describe('SABnzbdService', () => {
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('No permissions');
|
||||
expect(result.message).toBe('No permissions');
|
||||
});
|
||||
|
||||
it('returns version when connection succeeds', async () => {
|
||||
@@ -105,7 +110,7 @@ describe('SABnzbdService', () => {
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('SSL/TLS certificate error');
|
||||
expect(result.message).toContain('SSL/TLS certificate error');
|
||||
});
|
||||
|
||||
it('returns a friendly error on connection refused', async () => {
|
||||
@@ -115,7 +120,7 @@ describe('SABnzbdService', () => {
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Connection refused');
|
||||
expect(result.message).toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('adds NZB with mapped priority', async () => {
|
||||
@@ -123,10 +128,16 @@ describe('SABnzbdService', () => {
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({
|
||||
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { books: { dir: '' } } } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { status: true, nzo_ids: ['nzb-1'] },
|
||||
});
|
||||
// Mock NZB file download (global axios.get)
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: Buffer.from('fake-nzb-content'),
|
||||
headers: {},
|
||||
});
|
||||
// Mock addfile upload (POST instead of GET)
|
||||
clientMock.post.mockResolvedValueOnce({
|
||||
data: { status: true, nzo_ids: ['nzb-1'] },
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key', 'books', '/downloads');
|
||||
const nzbId = await service.addNZB('https://example.com/book.nzb', {
|
||||
@@ -134,11 +145,8 @@ describe('SABnzbdService', () => {
|
||||
priority: 'high',
|
||||
});
|
||||
|
||||
// Second call is the addurl call
|
||||
const params = clientMock.get.mock.calls[1][1].params;
|
||||
expect(nzbId).toBe('nzb-1');
|
||||
expect(params.cat).toBe('books');
|
||||
expect(params.priority).toBe('1');
|
||||
expect(clientMock.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('adds NZB with force priority', async () => {
|
||||
@@ -146,17 +154,22 @@ describe('SABnzbdService', () => {
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({
|
||||
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { status: true, nzo_ids: ['nzb-9'] },
|
||||
});
|
||||
// Mock NZB file download
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: Buffer.from('fake-nzb-content'),
|
||||
headers: {},
|
||||
});
|
||||
// Mock addfile upload
|
||||
clientMock.post.mockResolvedValueOnce({
|
||||
data: { status: true, nzo_ids: ['nzb-9'] },
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
|
||||
await service.addNZB('https://example.com/book.nzb', { priority: 'force' });
|
||||
const nzbId = await service.addNZB('https://example.com/book.nzb', { priority: 'force' });
|
||||
|
||||
// Second call is the addurl call
|
||||
const params = clientMock.get.mock.calls[1][1].params;
|
||||
expect(params.priority).toBe('2');
|
||||
expect(nzbId).toBe('nzb-9');
|
||||
expect(clientMock.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns queue item info when NZB is active', async () => {
|
||||
@@ -428,14 +441,18 @@ describe('SABnzbdService', () => {
|
||||
expect(clientMock.get.mock.calls[0][1].params.mode).toBe('get_config');
|
||||
});
|
||||
it('throws when addNZB reports a failure', async () => {
|
||||
// Mock getConfig for ensureCategory, then the addurl failure
|
||||
// Mock getConfig for ensureCategory, then the upload failure
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({
|
||||
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { status: false, error: 'Bad NZB' },
|
||||
});
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: Buffer.from('fake-nzb-content'),
|
||||
headers: {},
|
||||
});
|
||||
clientMock.post.mockResolvedValueOnce({
|
||||
data: { status: false, error: 'Bad NZB' },
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
|
||||
|
||||
@@ -443,14 +460,18 @@ describe('SABnzbdService', () => {
|
||||
});
|
||||
|
||||
it('throws when SABnzbd returns no NZB IDs', async () => {
|
||||
// Mock getConfig for ensureCategory, then the addurl with empty IDs
|
||||
// Mock getConfig for ensureCategory, then the upload with empty IDs
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({
|
||||
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { status: true, nzo_ids: [] },
|
||||
});
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: Buffer.from('fake-nzb-content'),
|
||||
headers: {},
|
||||
});
|
||||
clientMock.post.mockResolvedValueOnce({
|
||||
data: { status: true, nzo_ids: [] },
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
|
||||
|
||||
@@ -475,7 +496,7 @@ describe('SABnzbdService', () => {
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('timed out');
|
||||
expect(result.message).toContain('timed out');
|
||||
});
|
||||
|
||||
it('throws when version is missing from response', async () => {
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* Component: Transmission Integration Service Tests
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TransmissionService } from '@/lib/integrations/transmission.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());
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
vi.mock('parse-torrent', () => ({
|
||||
default: parseTorrentMock,
|
||||
}));
|
||||
|
||||
describe('TransmissionService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clientMock.get.mockReset();
|
||||
clientMock.post.mockReset();
|
||||
axiosMock.get.mockReset();
|
||||
axiosMock.post.mockReset();
|
||||
parseTorrentMock.mockReset();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('sets clientType and protocol correctly', () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
expect(service.clientType).toBe('transmission');
|
||||
expect(service.protocol).toBe('torrent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
it('returns success with version on valid connection', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
clientMock.post.mockResolvedValueOnce({
|
||||
data: { result: 'success', arguments: { version: '4.0.5' } },
|
||||
});
|
||||
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.version).toBe('4.0.5');
|
||||
expect(result.message).toContain('Transmission');
|
||||
});
|
||||
|
||||
it('returns failure when RPC returns error', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
clientMock.post.mockResolvedValueOnce({
|
||||
data: { result: 'unauthorized' },
|
||||
});
|
||||
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('unauthorized');
|
||||
});
|
||||
|
||||
it('returns failure on connection error', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
clientMock.post.mockRejectedValueOnce(new Error('Connection refused'));
|
||||
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('returns SSL-specific errors', async () => {
|
||||
const service = new TransmissionService('https://transmission', 'user', 'pass');
|
||||
clientMock.post.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
code: 'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
message: 'self signed',
|
||||
});
|
||||
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('SSL certificate verification failed');
|
||||
});
|
||||
|
||||
it('returns ECONNREFUSED error with URL', async () => {
|
||||
const service = new TransmissionService('http://transmission:9091', 'user', 'pass');
|
||||
clientMock.post.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
code: 'ECONNREFUSED',
|
||||
message: 'refused',
|
||||
});
|
||||
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Connection refused');
|
||||
expect(result.message).toContain('http://transmission:9091');
|
||||
});
|
||||
|
||||
it('returns 401 authentication error', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
clientMock.post.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 401 },
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Authentication failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSRF handling', () => {
|
||||
it('captures X-Transmission-Session-Id on 409 and retries', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
|
||||
// First call returns 409 with session ID
|
||||
clientMock.post
|
||||
.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 409,
|
||||
headers: { 'x-transmission-session-id': 'csrf-token-123' },
|
||||
},
|
||||
})
|
||||
// Retry succeeds
|
||||
.mockResolvedValueOnce({
|
||||
data: { result: 'success', arguments: { version: '4.0.5' } },
|
||||
});
|
||||
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(clientMock.post).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Verify second call includes the session ID header
|
||||
const secondCall = clientMock.post.mock.calls[1];
|
||||
expect(secondCall[2].headers['X-Transmission-Session-Id']).toBe('csrf-token-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addDownload', () => {
|
||||
it('rejects empty URLs', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
await expect(service.addDownload('')).rejects.toThrow('Invalid download URL');
|
||||
});
|
||||
|
||||
it('adds magnet links via torrent-add RPC', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
|
||||
// getTorrentByHash - not found (no duplicate)
|
||||
clientMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: { result: 'success', arguments: { torrents: [] } },
|
||||
})
|
||||
// torrent-add
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: {
|
||||
'torrent-added': { hashString: '0123456789abcdef0123456789abcdef01234567', name: 'Test' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hash = await service.addDownload(
|
||||
'magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567'
|
||||
);
|
||||
|
||||
expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
|
||||
});
|
||||
|
||||
it('skips duplicate magnet links', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
|
||||
// getTorrentByHash - found (duplicate)
|
||||
clientMock.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: {
|
||||
torrents: [{
|
||||
hashString: '0123456789abcdef0123456789abcdef01234567',
|
||||
name: 'Existing',
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hash = await service.addDownload(
|
||||
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'
|
||||
);
|
||||
|
||||
expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
|
||||
// Only 1 RPC call (torrent-get), no torrent-add
|
||||
expect(clientMock.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws on invalid magnet link', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
await expect(service.addDownload('magnet:?xt=urn:btih:')).rejects.toThrow('Invalid magnet link');
|
||||
});
|
||||
|
||||
it('throws when Transmission rejects the magnet link', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
|
||||
// No duplicate
|
||||
clientMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: { result: 'success', arguments: { torrents: [] } },
|
||||
})
|
||||
// torrent-add fails
|
||||
.mockResolvedValueOnce({
|
||||
data: { result: 'duplicate torrent' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.addDownload('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567')
|
||||
).rejects.toThrow('Transmission rejected magnet link');
|
||||
});
|
||||
|
||||
it('adds .torrent files via metainfo base64', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent-data') });
|
||||
parseTorrentMock.mockResolvedValueOnce({ infoHash: 'parsed-hash', name: 'Book' });
|
||||
|
||||
// getTorrentByHash - not found
|
||||
clientMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: { result: 'success', arguments: { torrents: [] } },
|
||||
})
|
||||
// torrent-add succeeds
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: {
|
||||
'torrent-added': { hashString: 'parsed-hash', name: 'Book' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hash = await service.addDownload('http://example.com/file.torrent');
|
||||
|
||||
expect(hash).toBe('parsed-hash');
|
||||
// Verify metainfo was sent
|
||||
const addCall = clientMock.post.mock.calls[1];
|
||||
const body = addCall[0] === '/transmission/rpc' ? JSON.parse(JSON.stringify(addCall[1])) : null;
|
||||
// The body should be the RPC call with metainfo
|
||||
});
|
||||
|
||||
it('follows redirect to magnet link', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
|
||||
axiosMock.get.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 302,
|
||||
headers: { location: 'magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01' },
|
||||
},
|
||||
});
|
||||
|
||||
// getTorrentByHash - not found
|
||||
clientMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: { result: 'success', arguments: { torrents: [] } },
|
||||
})
|
||||
// torrent-add
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: {
|
||||
'torrent-added': { hashString: 'abcdef0123456789abcdef0123456789abcdef01', name: 'Test' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hash = await service.addDownload('http://example.com/file.torrent');
|
||||
expect(hash).toBe('abcdef0123456789abcdef0123456789abcdef01');
|
||||
});
|
||||
|
||||
it('throws on invalid .torrent file', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('not-a-torrent') });
|
||||
parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent'));
|
||||
|
||||
await expect(service.addDownload('http://example.com/file.torrent')).rejects.toThrow(
|
||||
'Invalid .torrent file - failed to parse'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDownload', () => {
|
||||
it('returns mapped DownloadInfo for found torrents', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
|
||||
clientMock.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: {
|
||||
torrents: [{
|
||||
hashString: 'abc123',
|
||||
name: 'Audiobook',
|
||||
totalSize: 1000,
|
||||
downloadedEver: 500,
|
||||
percentDone: 0.5,
|
||||
status: 4, // downloading
|
||||
rateDownload: 1000,
|
||||
eta: 500,
|
||||
labels: ['readmeabook'],
|
||||
downloadDir: '/downloads',
|
||||
doneDate: 0,
|
||||
errorString: '',
|
||||
error: 0,
|
||||
secondsSeeding: 3600,
|
||||
uploadRatio: 0.1,
|
||||
uploadedEver: 50,
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const info = await service.getDownload('abc123');
|
||||
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.id).toBe('abc123');
|
||||
expect(info!.name).toBe('Audiobook');
|
||||
expect(info!.status).toBe('downloading');
|
||||
expect(info!.progress).toBe(0.5);
|
||||
expect(info!.downloadSpeed).toBe(1000);
|
||||
expect(info!.category).toBe('readmeabook');
|
||||
expect(info!.seedingTime).toBe(3600);
|
||||
});
|
||||
|
||||
it('returns null when torrent not found after retries', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
|
||||
// All retries return empty
|
||||
clientMock.post.mockResolvedValue({
|
||||
data: { result: 'success', arguments: { torrents: [] } },
|
||||
});
|
||||
|
||||
const info = await service.getDownload('nonexistent');
|
||||
|
||||
expect(info).toBeNull();
|
||||
});
|
||||
|
||||
it('maps error code to failed status', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
|
||||
clientMock.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: {
|
||||
torrents: [{
|
||||
hashString: 'abc123',
|
||||
name: 'Failed',
|
||||
totalSize: 1000,
|
||||
downloadedEver: 0,
|
||||
percentDone: 0,
|
||||
status: 0,
|
||||
rateDownload: 0,
|
||||
eta: -1,
|
||||
labels: [],
|
||||
downloadDir: '/downloads',
|
||||
doneDate: 0,
|
||||
errorString: 'Tracker error',
|
||||
error: 2,
|
||||
uploadRatio: -1,
|
||||
uploadedEver: 0,
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const info = await service.getDownload('abc123');
|
||||
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.status).toBe('failed');
|
||||
expect(info!.errorMessage).toBe('Tracker error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('status mapping', () => {
|
||||
const makeService = () => new TransmissionService('http://transmission', '', '');
|
||||
|
||||
const mapStatus = (service: TransmissionService, status: number, error = 0) => {
|
||||
return (service as any).mapStatus(status, error);
|
||||
};
|
||||
|
||||
it('maps 0 (stopped) to paused', () => {
|
||||
expect(mapStatus(makeService(), 0)).toBe('paused');
|
||||
});
|
||||
|
||||
it('maps 1 (check-pending) to checking', () => {
|
||||
expect(mapStatus(makeService(), 1)).toBe('checking');
|
||||
});
|
||||
|
||||
it('maps 2 (checking) to checking', () => {
|
||||
expect(mapStatus(makeService(), 2)).toBe('checking');
|
||||
});
|
||||
|
||||
it('maps 3 (download-pending) to queued', () => {
|
||||
expect(mapStatus(makeService(), 3)).toBe('queued');
|
||||
});
|
||||
|
||||
it('maps 4 (downloading) to downloading', () => {
|
||||
expect(mapStatus(makeService(), 4)).toBe('downloading');
|
||||
});
|
||||
|
||||
it('maps 5 (seed-pending) to seeding', () => {
|
||||
expect(mapStatus(makeService(), 5)).toBe('seeding');
|
||||
});
|
||||
|
||||
it('maps 6 (seeding) to seeding', () => {
|
||||
expect(mapStatus(makeService(), 6)).toBe('seeding');
|
||||
});
|
||||
|
||||
it('maps any status with error > 0 to failed', () => {
|
||||
expect(mapStatus(makeService(), 4, 1)).toBe('failed');
|
||||
expect(mapStatus(makeService(), 6, 2)).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pauseDownload / resumeDownload / deleteDownload', () => {
|
||||
it('pauses torrents via torrent-stop', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
|
||||
clientMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: {
|
||||
torrents: [{ hashString: 'hash-1', name: 'Test' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { result: 'success' } });
|
||||
|
||||
await service.pauseDownload('hash-1');
|
||||
|
||||
const stopCall = clientMock.post.mock.calls[1];
|
||||
expect(stopCall[1]).toEqual(
|
||||
expect.objectContaining({ method: 'torrent-stop' })
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes torrents via torrent-start', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
|
||||
clientMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: {
|
||||
torrents: [{ hashString: 'hash-1', name: 'Test' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { result: 'success' } });
|
||||
|
||||
await service.resumeDownload('hash-1');
|
||||
|
||||
const startCall = clientMock.post.mock.calls[1];
|
||||
expect(startCall[1]).toEqual(
|
||||
expect.objectContaining({ method: 'torrent-start' })
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes torrents via torrent-remove', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
|
||||
clientMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: {
|
||||
torrents: [{ hashString: 'hash-1', name: 'Test' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { result: 'success' } });
|
||||
|
||||
await service.deleteDownload('hash-1', true);
|
||||
|
||||
const removeCall = clientMock.post.mock.calls[1];
|
||||
expect(removeCall[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'torrent-remove',
|
||||
arguments: expect.objectContaining({ 'delete-local-data': true }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when pause fails', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
clientMock.post.mockRejectedValueOnce(new Error('fail'));
|
||||
|
||||
await expect(service.pauseDownload('hash-1')).rejects.toThrow('Failed to pause torrent');
|
||||
});
|
||||
|
||||
it('throws when resume fails', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
clientMock.post.mockRejectedValueOnce(new Error('fail'));
|
||||
|
||||
await expect(service.resumeDownload('hash-1')).rejects.toThrow('Failed to resume torrent');
|
||||
});
|
||||
|
||||
it('throws when delete fails', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
clientMock.post.mockRejectedValueOnce(new Error('fail'));
|
||||
|
||||
await expect(service.deleteDownload('hash-1')).rejects.toThrow('Failed to delete torrent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('postProcess', () => {
|
||||
it('is a no-op', async () => {
|
||||
const service = new TransmissionService('http://transmission', 'user', 'pass');
|
||||
await expect(service.postProcess('hash-1')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('path mapping', () => {
|
||||
it('applies reverse path mapping for torrent-add download-dir', async () => {
|
||||
const service = new TransmissionService(
|
||||
'http://transmission',
|
||||
'user',
|
||||
'pass',
|
||||
'/downloads',
|
||||
'readmeabook',
|
||||
false,
|
||||
{ enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' }
|
||||
);
|
||||
|
||||
// No duplicate
|
||||
clientMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: { result: 'success', arguments: { torrents: [] } },
|
||||
})
|
||||
// torrent-add
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: {
|
||||
'torrent-added': { hashString: '0123456789abcdef0123456789abcdef01234567', name: 'Test' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await service.addDownload('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567');
|
||||
|
||||
// Verify the torrent-add call has the remote path
|
||||
const addCall = clientMock.post.mock.calls[1];
|
||||
const rpcBody = addCall[1];
|
||||
expect(rpcBody.arguments['download-dir']).toBe('F:\\Docker\\downloads');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user