Files
ReadMeABook/tests/integrations/nzbget.service.test.ts
kikootwo 4b90b35748 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.
2026-02-09 19:45:43 -05:00

1305 lines
46 KiB
TypeScript

/**
* Component: NZBGet Integration Service Tests
* Documentation: documentation/phase3/download-clients.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NZBGetService, getNZBGetService, invalidateNZBGetService } from '@/lib/integrations/nzbget.service';
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(() => ({
get: vi.fn(),
}));
const downloadClientManagerMock = vi.hoisted(() => ({
getClientForProtocol: vi.fn(),
getAllClients: vi.fn(),
hasClientForProtocol: vi.fn(),
}));
vi.mock('axios', () => ({
default: axiosMock,
...axiosMock,
}));
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('NZBGetService', () => {
beforeEach(() => {
vi.clearAllMocks();
clientMock.get.mockReset();
clientMock.post.mockReset();
axiosMock.get.mockReset();
axiosMock.isAxiosError.mockReset();
axiosMock.isAxiosError.mockReturnValue(false);
configServiceMock.get.mockReset();
downloadClientManagerMock.getClientForProtocol.mockReset();
downloadClientManagerMock.getAllClients.mockReset();
downloadClientManagerMock.hasClientForProtocol.mockReset();
invalidateNZBGetService();
});
it('has correct clientType and protocol', () => {
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
expect(service.clientType).toBe('nzbget');
expect(service.protocol).toBe('usenet');
});
// =========================================================================
// Connection Testing
// =========================================================================
describe('testConnection', () => {
it('returns version when connection succeeds', async () => {
clientMock.post.mockResolvedValueOnce({
data: { result: '24.3' },
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const result = await service.testConnection();
expect(result.success).toBe(true);
expect(result.version).toBe('24.3');
expect(result.message).toContain('Connected to NZBGet v24.3');
expect(clientMock.post).toHaveBeenCalledWith('/jsonrpc', {
method: 'version',
params: [],
});
});
it('fails when version is empty', async () => {
clientMock.post.mockResolvedValueOnce({
data: { result: '' },
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('failed to get NZBGet version');
});
it('returns friendly error on 401 authentication failure', async () => {
const authError = new Error('Request failed with status code 401') as any;
authError.response = { status: 401 };
authError.isAxiosError = true;
axiosMock.isAxiosError.mockReturnValue(true);
clientMock.post.mockRejectedValueOnce(authError);
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'wrong');
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Authentication failed');
});
it('returns friendly error on connection refused', async () => {
const connError = new Error('connect ECONNREFUSED') as any;
connError.code = 'ECONNREFUSED';
connError.isAxiosError = true;
axiosMock.isAxiosError.mockReturnValue(true);
clientMock.post.mockRejectedValueOnce(connError);
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Connection refused');
});
it('returns friendly error on timeout', async () => {
const timeoutError = new Error('timeout of 30000ms exceeded') as any;
timeoutError.code = 'ETIMEDOUT';
timeoutError.isAxiosError = true;
axiosMock.isAxiosError.mockReturnValue(true);
clientMock.post.mockRejectedValueOnce(timeoutError);
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('timed out');
});
it('returns SSL error for certificate issues', async () => {
clientMock.post.mockRejectedValueOnce(new Error('SSL certificate error'));
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('SSL');
});
it('returns RPC error message from server', async () => {
clientMock.post.mockResolvedValueOnce({
data: { error: { message: 'Method not found' } },
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Method not found');
});
});
// =========================================================================
// Adding Downloads
// =========================================================================
describe('addDownload', () => {
it('downloads NZB file and uploads to NZBGet via append', async () => {
// Mock ensureCategory: config() (category already exists)
clientMock.post
.mockResolvedValueOnce({
data: {
result: [
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'Category1.Name', Value: 'readmeabook' },
{ Name: 'Category1.DestDir', Value: '/downloads' },
],
},
})
// Mock append()
.mockResolvedValueOnce({
data: { result: 12345 },
});
// Mock NZB file download
axiosMock.get.mockResolvedValueOnce({
data: Buffer.from('fake-nzb-content'),
headers: { 'content-disposition': 'attachment; filename="My.Audiobook.nzb"' },
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
const id = await service.addDownload('https://prowlarr.local/api/download/123', {
category: 'readmeabook',
priority: 'normal',
});
expect(id).toBe('12345');
// Verify append call
const appendCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'append'
);
expect(appendCall).toBeDefined();
const [, body] = appendCall!;
expect(body.method).toBe('append');
expect(body.params[0]).toBe('My.Audiobook.nzb'); // Filename
expect(body.params[2]).toBe('readmeabook'); // Category
expect(body.params[3]).toBe(0); // Normal priority
});
it('maps high priority correctly', async () => {
clientMock.post
.mockResolvedValueOnce({
data: {
result: [
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'Category1.Name', Value: 'readmeabook' },
{ Name: 'Category1.DestDir', Value: '/downloads' },
],
},
})
.mockResolvedValueOnce({ data: { result: 99 } });
axiosMock.get.mockResolvedValueOnce({
data: Buffer.from('nzb'),
headers: {},
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
const id = await service.addDownload('https://example.com/book.nzb', { priority: 'high' });
expect(id).toBe('99');
const appendCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'append'
);
expect(appendCall![1].params[3]).toBe(50); // High = 50
});
it('maps force priority correctly', async () => {
clientMock.post
.mockResolvedValueOnce({
data: {
result: [
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'Category1.Name', Value: 'readmeabook' },
{ Name: 'Category1.DestDir', Value: '/downloads' },
],
},
})
.mockResolvedValueOnce({ data: { result: 100 } });
axiosMock.get.mockResolvedValueOnce({
data: Buffer.from('nzb'),
headers: {},
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
await service.addDownload('https://example.com/book.nzb', { priority: 'force' });
const appendCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'append'
);
expect(appendCall![1].params[3]).toBe(900); // Force = 900
});
it('throws when NZBGet rejects the NZB', async () => {
clientMock.post
.mockResolvedValueOnce({
data: {
result: [
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'Category1.Name', Value: 'readmeabook' },
{ Name: 'Category1.DestDir', Value: '/downloads' },
],
},
})
.mockResolvedValueOnce({ data: { result: 0 } }); // 0 = rejected
axiosMock.get.mockResolvedValueOnce({
data: Buffer.from('nzb'),
headers: {},
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
await expect(service.addDownload('https://example.com/bad.nzb')).rejects.toThrow('rejected');
});
it('throws when NZB file download fails with HTTP error', async () => {
clientMock.post.mockResolvedValueOnce({
data: {
result: [
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'Category1.Name', Value: 'readmeabook' },
{ Name: 'Category1.DestDir', Value: '/downloads' },
],
},
});
const httpError = new Error('Request failed') as any;
httpError.response = { status: 404 };
httpError.isAxiosError = true;
axiosMock.isAxiosError.mockReturnValue(true);
axiosMock.get.mockRejectedValueOnce(httpError);
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
await expect(service.addDownload('https://example.com/missing.nzb')).rejects.toThrow('HTTP 404');
});
it('throws when NZB file is empty', async () => {
clientMock.post.mockResolvedValueOnce({
data: {
result: [
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'Category1.Name', Value: 'readmeabook' },
{ Name: 'Category1.DestDir', Value: '/downloads' },
],
},
});
axiosMock.get.mockResolvedValueOnce({
data: Buffer.from(''),
headers: {},
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
await expect(service.addDownload('https://example.com/empty.nzb')).rejects.toThrow('empty');
});
it('extracts filename from URL when no Content-Disposition', async () => {
clientMock.post
.mockResolvedValueOnce({
data: {
result: [
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'Category1.Name', Value: 'readmeabook' },
{ Name: 'Category1.DestDir', Value: '/downloads' },
],
},
})
.mockResolvedValueOnce({ data: { result: 50 } });
axiosMock.get.mockResolvedValueOnce({
data: Buffer.from('nzb-content'),
headers: {}, // No content-disposition
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
await service.addDownload('https://example.com/My.Great.Audiobook.nzb');
const appendCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'append'
);
expect(appendCall![1].params[0]).toBe('My.Great.Audiobook.nzb');
});
it('decompresses gzip-compressed NZB files before uploading', async () => {
const zlib = await import('zlib');
const nzbXml = '<?xml version="1.0" encoding="UTF-8"?><nzb><file></file></nzb>';
const compressedNzb = zlib.gzipSync(Buffer.from(nzbXml));
clientMock.post
.mockResolvedValueOnce({
data: {
result: [
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'Category1.Name', Value: 'readmeabook' },
{ Name: 'Category1.DestDir', Value: '/downloads' },
],
},
})
.mockResolvedValueOnce({ data: { result: 777 } });
axiosMock.get.mockResolvedValueOnce({
data: compressedNzb,
headers: { 'content-disposition': 'attachment; filename="Book.nzb"' },
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
const id = await service.addDownload('https://example.com/nzb.gz');
expect(id).toBe('777');
// Verify the base64 content sent to NZBGet is the decompressed XML, not compressed bytes
const appendCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'append'
);
const sentBase64 = appendCall![1].params[1];
const decodedContent = Buffer.from(sentBase64, 'base64').toString('utf-8');
expect(decodedContent).toBe(nzbXml);
});
it('falls back to download.nzb when filename cannot be extracted', async () => {
clientMock.post
.mockResolvedValueOnce({
data: {
result: [
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'Category1.Name', Value: 'readmeabook' },
{ Name: 'Category1.DestDir', Value: '/downloads' },
],
},
})
.mockResolvedValueOnce({ data: { result: 51 } });
axiosMock.get.mockResolvedValueOnce({
data: Buffer.from('nzb-content'),
headers: {},
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
await service.addDownload('https://example.com/download');
const appendCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'append'
);
expect(appendCall![1].params[0]).toBe('download.nzb');
});
});
// =========================================================================
// Getting Downloads
// =========================================================================
describe('getDownload', () => {
it('returns queue item when download is active', async () => {
// Mock listgroups (queue check)
clientMock.post
.mockResolvedValueOnce({
data: {
result: [
{
NZBID: 100,
NZBName: 'Active Book',
Status: 'DOWNLOADING',
FileSizeMB: 500,
DownloadedSizeMB: 250,
RemainingSizeMB: 250,
DownloadTimeSec: 120,
Category: 'readmeabook',
DestDir: '/downloads/readmeabook/Active.Book',
FinalDir: '',
MaxPriority: 0,
ActiveDownloads: 1,
Health: 1000,
PostInfoText: '',
PostStageProgress: 0,
},
],
},
})
// Mock status() for download speed (called inside mapGroupToDownloadInfo)
.mockResolvedValueOnce({
data: { result: { DownloadRate: 5242880 } }, // 5 MB/s
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const info = await service.getDownload('100');
expect(info).not.toBeNull();
expect(info!.id).toBe('100');
expect(info!.name).toBe('Active Book');
expect(info!.status).toBe('downloading');
expect(info!.progress).toBe(0.5);
expect(info!.size).toBe(500 * 1024 * 1024);
expect(info!.bytesDownloaded).toBe(250 * 1024 * 1024);
expect(info!.category).toBe('readmeabook');
expect(info!.downloadSpeed).toBe(5242880);
});
it('returns history item when download is completed', async () => {
// Mock listgroups (empty queue)
clientMock.post
.mockResolvedValueOnce({ data: { result: [] } })
// Mock history
.mockResolvedValueOnce({
data: {
result: [
{
NZBID: 200,
Name: 'Completed Book',
Status: 'SUCCESS/ALL',
Category: 'readmeabook',
FileSizeMB: 300,
DownloadedSizeMB: 300,
DestDir: '/downloads/readmeabook/Completed.Book',
FinalDir: '/downloads/readmeabook/Completed.Book',
DownloadTimeSec: 60,
PostTotalTimeSec: 30,
ParStatus: 'SUCCESS',
UnpackStatus: 'SUCCESS',
DeleteStatus: 'NONE',
MarkStatus: 'NONE',
HistoryTime: 1700000000,
FailedArticles: 0,
TotalArticles: 1000,
},
],
},
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const info = await service.getDownload('200');
expect(info).not.toBeNull();
expect(info!.id).toBe('200');
expect(info!.name).toBe('Completed Book');
expect(info!.status).toBe('completed');
expect(info!.progress).toBe(1.0);
expect(info!.bytesDownloaded).toBe(300 * 1024 * 1024);
expect(info!.completedAt?.getTime()).toBe(1700000000 * 1000);
expect(info!.downloadPath).toBe('/downloads/readmeabook/Completed.Book');
});
it('returns history item with failed status and error message', async () => {
clientMock.post
.mockResolvedValueOnce({ data: { result: [] } })
.mockResolvedValueOnce({
data: {
result: [
{
NZBID: 300,
Name: 'Failed Book',
Status: 'FAILURE/PAR',
Category: 'readmeabook',
FileSizeMB: 100,
DownloadedSizeMB: 80,
DestDir: '/downloads/Failed.Book',
FinalDir: '',
DownloadTimeSec: 45,
PostTotalTimeSec: 10,
ParStatus: 'FAILURE',
UnpackStatus: 'NONE',
DeleteStatus: 'NONE',
MarkStatus: 'NONE',
HistoryTime: 1700000100,
FailedArticles: 50,
TotalArticles: 500,
},
],
},
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const info = await service.getDownload('300');
expect(info!.status).toBe('failed');
expect(info!.errorMessage).toContain('FAILURE/PAR');
expect(info!.errorMessage).toContain('Par: FAILURE');
expect(info!.errorMessage).toContain('50 failed articles (10%)');
});
it('returns null when download is not found', async () => {
clientMock.post
.mockResolvedValueOnce({ data: { result: [] } })
.mockResolvedValueOnce({ data: { result: [] } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const info = await service.getDownload('999');
expect(info).toBeNull();
});
it('returns null for invalid NZB ID', async () => {
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const info = await service.getDownload('not-a-number');
expect(info).toBeNull();
});
it('applies path mapping to download path', async () => {
clientMock.post
.mockResolvedValueOnce({ data: { result: [] } })
.mockResolvedValueOnce({
data: {
result: [
{
NZBID: 400,
Name: 'Mapped Book',
Status: 'SUCCESS/ALL',
Category: 'readmeabook',
FileSizeMB: 200,
DownloadedSizeMB: 200,
DestDir: '/remote/downloads/readmeabook/Mapped.Book',
FinalDir: '/remote/downloads/readmeabook/Mapped.Book',
DownloadTimeSec: 30,
PostTotalTimeSec: 15,
ParStatus: 'SUCCESS',
UnpackStatus: 'SUCCESS',
DeleteStatus: 'NONE',
MarkStatus: 'NONE',
HistoryTime: 1700000200,
FailedArticles: 0,
TotalArticles: 800,
},
],
},
});
const service = new NZBGetService(
'http://nzbget:6789', 'nzbget', 'pass',
'readmeabook', '/downloads', false,
{ enabled: true, remotePath: '/remote/downloads', localPath: '/downloads' }
);
const info = await service.getDownload('400');
// Path mapping is now applied downstream by consumers, not by the service itself.
// The service returns the raw path from NZBGet.
const normalizedPath = info!.downloadPath!.replace(/\\/g, '/');
expect(normalizedPath).toContain('/remote/downloads/readmeabook/Mapped.Book');
});
});
// =========================================================================
// Status Mapping
// =========================================================================
describe('status mapping', () => {
const makeQueueItem = (status: string) => ({
NZBID: 1,
NZBName: 'Test',
Status: status,
FileSizeMB: 100,
DownloadedSizeMB: 50,
RemainingSizeMB: 50,
DownloadTimeSec: 60,
Category: '',
DestDir: '',
FinalDir: '',
MaxPriority: 0,
ActiveDownloads: 0,
Health: 1000,
PostInfoText: '',
PostStageProgress: 0,
});
const makeHistoryItem = (status: string) => ({
NZBID: 1,
Name: 'Test',
Status: status,
Category: '',
FileSizeMB: 100,
DownloadedSizeMB: 100,
DestDir: '',
FinalDir: '',
DownloadTimeSec: 60,
PostTotalTimeSec: 10,
ParStatus: 'NONE',
UnpackStatus: 'NONE',
DeleteStatus: 'NONE',
MarkStatus: 'NONE',
HistoryTime: 0,
FailedArticles: 0,
TotalArticles: 100,
});
it.each([
['QUEUED', 'queued'],
['PAUSED', 'paused'],
['DOWNLOADING', 'downloading'],
['FETCHING', 'downloading'],
['PP_QUEUED', 'processing'],
['LOADING_PARS', 'processing'],
['VERIFYING_SOURCES', 'processing'],
['REPAIRING', 'processing'],
['VERIFYING_REPAIRED', 'processing'],
['RENAMING', 'processing'],
['UNPACKING', 'processing'],
['MOVING', 'processing'],
['EXECUTING_SCRIPT', 'processing'],
['PP_FINISHED', 'processing'],
])('maps queue status %s to %s', async (nzbgetStatus, expectedStatus) => {
clientMock.post.mockResolvedValueOnce({
data: { result: [makeQueueItem(nzbgetStatus)] },
});
// Mock status() for downloading items
if (expectedStatus === 'downloading') {
clientMock.post.mockResolvedValueOnce({
data: { result: { DownloadRate: 0 } },
});
}
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const info = await service.getDownload('1');
expect(info!.status).toBe(expectedStatus);
});
it.each([
['SUCCESS/ALL', 'completed'],
['SUCCESS/UNPACK', 'completed'],
['SUCCESS/PAR', 'completed'],
['SUCCESS/HEALTH', 'completed'],
['SUCCESS/GOOD', 'completed'],
['SUCCESS/MARK', 'completed'],
['WARNING/SCRIPT', 'completed'],
['WARNING/SPACE', 'completed'],
['WARNING/PASSWORD', 'completed'],
['WARNING/HEALTH', 'completed'],
['FAILURE/PAR', 'failed'],
['FAILURE/UNPACK', 'failed'],
['FAILURE/HEALTH', 'failed'],
['DELETED/MANUAL', 'failed'],
['DELETED/DUPE', 'failed'],
])('maps history status %s to %s', async (nzbgetStatus, expectedStatus) => {
clientMock.post
.mockResolvedValueOnce({ data: { result: [] } }) // Empty queue
.mockResolvedValueOnce({
data: { result: [makeHistoryItem(nzbgetStatus)] },
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const info = await service.getDownload('1');
expect(info!.status).toBe(expectedStatus);
});
it('defaults unknown queue status to downloading', async () => {
clientMock.post
.mockResolvedValueOnce({
data: { result: [makeQueueItem('UNKNOWN_STATUS')] },
})
.mockResolvedValueOnce({
data: { result: { DownloadRate: 0 } },
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const info = await service.getDownload('1');
expect(info!.status).toBe('downloading');
});
});
// =========================================================================
// Pause / Resume / Delete
// =========================================================================
describe('pauseDownload', () => {
it('calls editqueue with GroupPause', async () => {
clientMock.post.mockResolvedValueOnce({ data: { result: true } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
await service.pauseDownload('100');
expect(clientMock.post).toHaveBeenCalledWith('/jsonrpc', {
method: 'editqueue',
params: ['GroupPause', '', [100]],
});
});
it('throws when pause fails', async () => {
clientMock.post.mockResolvedValueOnce({ data: { result: false } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
await expect(service.pauseDownload('100')).rejects.toThrow('Failed to pause');
});
});
describe('resumeDownload', () => {
it('calls editqueue with GroupResume', async () => {
clientMock.post.mockResolvedValueOnce({ data: { result: true } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
await service.resumeDownload('100');
expect(clientMock.post).toHaveBeenCalledWith('/jsonrpc', {
method: 'editqueue',
params: ['GroupResume', '', [100]],
});
});
it('throws when resume fails', async () => {
clientMock.post.mockResolvedValueOnce({ data: { result: false } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
await expect(service.resumeDownload('100')).rejects.toThrow('Failed to resume');
});
});
describe('deleteDownload', () => {
it('deletes from queue with GroupFinalDelete when deleteFiles is true', async () => {
// Mock listgroups to find item in queue
clientMock.post
.mockResolvedValueOnce({
data: { result: [{ NZBID: 100 }] },
})
// Mock editqueue GroupFinalDelete
.mockResolvedValueOnce({ data: { result: true } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
await service.deleteDownload('100', true);
const deleteCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'editqueue'
);
expect(deleteCall![1].params[0]).toBe('GroupFinalDelete');
});
it('deletes from queue with GroupDelete when deleteFiles is false', async () => {
clientMock.post
.mockResolvedValueOnce({
data: { result: [{ NZBID: 100 }] },
})
.mockResolvedValueOnce({ data: { result: true } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
await service.deleteDownload('100', false);
const deleteCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'editqueue'
);
expect(deleteCall![1].params[0]).toBe('GroupDelete');
});
it('deletes from history when not in queue', async () => {
// Empty queue
clientMock.post
.mockResolvedValueOnce({ data: { result: [] } })
// Mock editqueue HistoryFinalDelete
.mockResolvedValueOnce({ data: { result: true } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
await service.deleteDownload('200', true);
const deleteCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'editqueue'
);
expect(deleteCall![1].params[0]).toBe('HistoryFinalDelete');
});
it('throws when delete from history fails', async () => {
clientMock.post
.mockResolvedValueOnce({ data: { result: [] } })
.mockResolvedValueOnce({ data: { result: false } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
await expect(service.deleteDownload('999', true)).rejects.toThrow('Failed to delete');
});
});
// =========================================================================
// Post-Process (Archive from History)
// =========================================================================
describe('postProcess', () => {
it('archives completed download from history via HistoryDelete', async () => {
clientMock.post.mockResolvedValueOnce({ data: { result: true } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
await service.postProcess('200');
expect(clientMock.post).toHaveBeenCalledWith('/jsonrpc', {
method: 'editqueue',
params: ['HistoryDelete', '', [200]],
});
});
it('throws when archive fails', async () => {
clientMock.post.mockResolvedValueOnce({ data: { result: false } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
await expect(service.postProcess('200')).rejects.toThrow('not found in history or failed to archive');
});
});
// =========================================================================
// Category Management
// =========================================================================
describe('ensureCategory', () => {
it('creates category and preserves all existing config', async () => {
const existingConfig = [
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'MainDir', Value: '/root/downloads' },
{ Name: 'ServerHost', Value: '0.0.0.0' },
// Read-only entries returned by config() that must NOT be saved back
{ Name: 'ConfigFile', Value: '/config/nzbget.conf' },
{ Name: 'AppBin', Value: '/app/nzbget/nzbget' },
{ Name: 'AppDir', Value: '/app/nzbget' },
{ Name: 'Version', Value: '26.0' },
];
clientMock.post
// config()
.mockResolvedValueOnce({ data: { result: existingConfig } })
// saveconfig()
.mockResolvedValueOnce({ data: { result: true } })
// reload()
.mockResolvedValueOnce({ data: { result: true } })
// version() poll
.mockResolvedValueOnce({ data: { result: '21.1' } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
await service.ensureCategory();
const saveCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'saveconfig'
);
expect(saveCall).toBeDefined();
const savedConfig = saveCall![1].params[0];
// Must contain ALL original writable entries (not wiped)
expect(savedConfig).toEqual(expect.arrayContaining([
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'MainDir', Value: '/root/downloads' },
{ Name: 'ServerHost', Value: '0.0.0.0' },
]));
// Plus our new category entries
expect(savedConfig).toEqual(expect.arrayContaining([
{ Name: 'Category1.Name', Value: 'readmeabook' },
{ Name: 'Category1.DestDir', Value: '/downloads' },
{ Name: 'Category1.Unpack', Value: 'yes' },
]));
// Read-only entries must NOT be in the saved config
const savedNames = savedConfig.map((e: any) => e.Name);
expect(savedNames).not.toContain('ConfigFile');
expect(savedNames).not.toContain('AppBin');
expect(savedNames).not.toContain('AppDir');
expect(savedNames).not.toContain('Version');
// Verify reload was called to apply changes
const reloadCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'reload'
);
expect(reloadCall).toBeDefined();
});
it('uses next available slot and preserves existing categories', async () => {
const existingConfig = [
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'Category1.Name', Value: 'movies' },
{ Name: 'Category1.DestDir', Value: '/downloads/movies' },
{ Name: 'Category2.Name', Value: 'tv' },
{ Name: 'Category2.DestDir', Value: '/downloads/tv' },
];
clientMock.post
.mockResolvedValueOnce({ data: { result: existingConfig } })
.mockResolvedValueOnce({ data: { result: true } })
// reload + version
.mockResolvedValueOnce({ data: { result: true } })
.mockResolvedValueOnce({ data: { result: '21.1' } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
await service.ensureCategory();
const saveCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'saveconfig'
);
const savedConfig = saveCall![1].params[0];
// Existing categories preserved
expect(savedConfig).toEqual(expect.arrayContaining([
{ Name: 'Category1.Name', Value: 'movies' },
{ Name: 'Category1.DestDir', Value: '/downloads/movies' },
{ Name: 'Category2.Name', Value: 'tv' },
{ Name: 'Category2.DestDir', Value: '/downloads/tv' },
]));
// New category in slot 3
expect(savedConfig).toEqual(expect.arrayContaining([
{ Name: 'Category3.Name', Value: 'readmeabook' },
{ Name: 'Category3.DestDir', Value: '/downloads' },
]));
});
it('does not update when category exists with correct path', async () => {
clientMock.post.mockResolvedValueOnce({
data: {
result: [
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'Category1.Name', Value: 'readmeabook' },
{ Name: 'Category1.DestDir', Value: '/downloads' },
],
},
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
await service.ensureCategory();
// Only config() should be called — no saveconfig, no reload
expect(clientMock.post).toHaveBeenCalledTimes(1);
expect(clientMock.post.mock.calls[0][1].method).toBe('config');
});
it('updates category DestDir preserving full config and reloads', async () => {
const existingConfig = [
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'ServerHost', Value: '0.0.0.0' },
{ Name: 'Category1.Name', Value: 'readmeabook' },
{ Name: 'Category1.DestDir', Value: '/old/path' },
// Read-only entries that must be filtered out
{ Name: 'ConfigFile', Value: '/config/nzbget.conf' },
{ Name: 'AppBin', Value: '/app/nzbget/nzbget' },
{ Name: 'AppDir', Value: '/app/nzbget' },
{ Name: 'Version', Value: '26.0' },
];
clientMock.post
.mockResolvedValueOnce({ data: { result: existingConfig } })
.mockResolvedValueOnce({ data: { result: true } })
// reload + version
.mockResolvedValueOnce({ data: { result: true } })
.mockResolvedValueOnce({ data: { result: '21.1' } });
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
await service.ensureCategory();
const saveCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'saveconfig'
);
const savedConfig = saveCall![1].params[0];
// Full writable config preserved with updated DestDir — no read-only entries
expect(savedConfig).toEqual([
{ Name: 'DestDir', Value: '/downloads' },
{ Name: 'ServerHost', Value: '0.0.0.0' },
{ Name: 'Category1.Name', Value: 'readmeabook' },
{ Name: 'Category1.DestDir', Value: '/downloads' },
]);
});
it('applies reverse path mapping for category DestDir', async () => {
const existingConfig = [
{ Name: 'DestDir', Value: '/remote/downloads' },
];
clientMock.post
.mockResolvedValueOnce({ data: { result: existingConfig } })
.mockResolvedValueOnce({ data: { result: true } })
// reload + version
.mockResolvedValueOnce({ data: { result: true } })
.mockResolvedValueOnce({ data: { result: '21.1' } });
const service = new NZBGetService(
'http://nzbget:6789', 'nzbget', 'pass',
'readmeabook', '/downloads', false,
{ enabled: true, remotePath: '/remote/downloads', localPath: '/downloads' }
);
await service.ensureCategory();
const saveCall = clientMock.post.mock.calls.find(
(call: any[]) => call[1]?.method === 'saveconfig'
);
const savedConfig = saveCall![1].params[0];
// After reverse transform: /downloads → /remote/downloads
const destDirEntry = savedConfig.find(
(e: any) => e.Name === 'Category1.DestDir'
);
expect(destDirEntry.Value).toBe('/remote/downloads');
// Original config preserved
expect(savedConfig).toEqual(expect.arrayContaining([
{ Name: 'DestDir', Value: '/remote/downloads' },
]));
});
it('continues if reload fails after saveconfig', async () => {
clientMock.post
.mockResolvedValueOnce({
data: {
result: [
{ Name: 'DestDir', Value: '/downloads' },
],
},
})
.mockResolvedValueOnce({ data: { result: true } })
// reload fails
.mockRejectedValueOnce(new Error('Connection reset'));
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
// Should not throw — reload failure is handled gracefully
await expect(service.ensureCategory()).resolves.toBeUndefined();
});
it('swallows errors when category management fails', async () => {
clientMock.post.mockRejectedValueOnce(new Error('Config read failed'));
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads');
// Should not throw
await expect(service.ensureCategory()).resolves.toBeUndefined();
});
});
// =========================================================================
// Error Message Building
// =========================================================================
describe('error messages', () => {
it('builds descriptive error for failed history item with unpack failure', async () => {
clientMock.post
.mockResolvedValueOnce({ data: { result: [] } })
.mockResolvedValueOnce({
data: {
result: [
{
NZBID: 500,
Name: 'Unpack Fail Book',
Status: 'FAILURE/UNPACK',
Category: '',
FileSizeMB: 100,
DownloadedSizeMB: 100,
DestDir: '',
FinalDir: '',
DownloadTimeSec: 60,
PostTotalTimeSec: 5,
ParStatus: 'SUCCESS',
UnpackStatus: 'FAILURE',
DeleteStatus: 'NONE',
MarkStatus: 'NONE',
HistoryTime: 0,
FailedArticles: 0,
TotalArticles: 100,
},
],
},
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const info = await service.getDownload('500');
expect(info!.errorMessage).toContain('FAILURE/UNPACK');
expect(info!.errorMessage).toContain('Unpack: FAILURE');
});
it('includes delete status in error message when present', async () => {
clientMock.post
.mockResolvedValueOnce({ data: { result: [] } })
.mockResolvedValueOnce({
data: {
result: [
{
NZBID: 600,
Name: 'Deleted Book',
Status: 'DELETED/HEALTH',
Category: '',
FileSizeMB: 100,
DownloadedSizeMB: 50,
DestDir: '',
FinalDir: '',
DownloadTimeSec: 30,
PostTotalTimeSec: 0,
ParStatus: 'NONE',
UnpackStatus: 'NONE',
DeleteStatus: 'HEALTH',
MarkStatus: 'NONE',
HistoryTime: 0,
FailedArticles: 100,
TotalArticles: 500,
},
],
},
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const info = await service.getDownload('600');
expect(info!.errorMessage).toContain('DELETED/HEALTH');
expect(info!.errorMessage).toContain('Delete: HEALTH');
expect(info!.errorMessage).toContain('100 failed articles (20%)');
});
});
// =========================================================================
// Singleton Factory
// =========================================================================
describe('singleton factory', () => {
it('creates a singleton service from config', async () => {
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'nzbget',
name: 'NZBGet',
enabled: true,
url: 'http://nzbget:6789',
username: 'nzbget',
password: 'password123',
disableSSLVerify: false,
remotePathMappingEnabled: false,
category: 'readmeabook',
});
configServiceMock.get.mockResolvedValue('/downloads');
const ensureSpy = vi.spyOn(NZBGetService.prototype, 'ensureCategory').mockResolvedValue();
const service = await getNZBGetService();
const again = await getNZBGetService();
expect(service).toBe(again); // Same instance
expect(ensureSpy).toHaveBeenCalled();
ensureSpy.mockRestore();
});
it('creates singleton with path mapping config', async () => {
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-2',
type: 'nzbget',
name: 'NZBGet',
enabled: true,
url: 'http://nzbget:6789',
username: 'nzbget',
password: 'password123',
disableSSLVerify: false,
remotePathMappingEnabled: true,
remotePath: '/remote/downloads',
localPath: '/downloads',
category: 'readmeabook',
});
configServiceMock.get.mockResolvedValue('/downloads');
const ensureSpy = vi.spyOn(NZBGetService.prototype, 'ensureCategory').mockResolvedValue();
const service = await getNZBGetService();
expect(service).toBeDefined();
expect(ensureSpy).toHaveBeenCalled();
ensureSpy.mockRestore();
});
it('throws when no usenet client is configured', async () => {
downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null);
await expect(getNZBGetService()).rejects.toThrow('not configured');
});
it('throws when configured usenet client is not NZBGet', async () => {
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-3',
type: 'sabnzbd',
name: 'SABnzbd',
enabled: true,
url: 'http://sab',
password: 'api-key',
});
await expect(getNZBGetService()).rejects.toThrow('Expected NZBGet');
});
it('invalidates singleton and recreates on next call', async () => {
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-4',
type: 'nzbget',
name: 'NZBGet',
enabled: true,
url: 'http://nzbget:6789',
username: 'nzbget',
password: 'pass',
disableSSLVerify: false,
remotePathMappingEnabled: false,
category: 'readmeabook',
});
configServiceMock.get.mockResolvedValue('/downloads');
const ensureSpy = vi.spyOn(NZBGetService.prototype, 'ensureCategory').mockResolvedValue();
const first = await getNZBGetService();
invalidateNZBGetService();
const second = await getNZBGetService();
expect(first).not.toBe(second);
ensureSpy.mockRestore();
});
});
// =========================================================================
// Usenet-specific fields
// =========================================================================
describe('usenet-specific behavior', () => {
it('returns undefined for seeding-related fields', async () => {
clientMock.post
.mockResolvedValueOnce({ data: { result: [] } })
.mockResolvedValueOnce({
data: {
result: [
{
NZBID: 700,
Name: 'Usenet Book',
Status: 'SUCCESS/ALL',
Category: 'readmeabook',
FileSizeMB: 100,
DownloadedSizeMB: 100,
DestDir: '/downloads',
FinalDir: '',
DownloadTimeSec: 30,
PostTotalTimeSec: 10,
ParStatus: 'SUCCESS',
UnpackStatus: 'SUCCESS',
DeleteStatus: 'NONE',
MarkStatus: 'NONE',
HistoryTime: 1700000000,
FailedArticles: 0,
TotalArticles: 100,
},
],
},
});
const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass');
const info = await service.getDownload('700');
expect(info!.seedingTime).toBeUndefined();
expect(info!.ratio).toBeUndefined();
});
});
});