Files
ReadMeABook/tests/integrations/sabnzbd.service.test.ts
T
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

817 lines
25 KiB
TypeScript

/**
* Component: SABnzbd Integration Service Tests
* Documentation: documentation/phase3/sabnzbd.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SABnzbdService, getSABnzbdService, invalidateSABnzbdService } from '@/lib/integrations/sabnzbd.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(),
}));
// Mock for DownloadClientManager
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('SABnzbdService', () => {
beforeEach(() => {
vi.clearAllMocks();
clientMock.get.mockReset();
clientMock.post.mockReset();
axiosMock.get.mockReset();
configServiceMock.get.mockReset();
downloadClientManagerMock.getClientForProtocol.mockReset();
downloadClientManagerMock.getAllClients.mockReset();
downloadClientManagerMock.hasClientForProtocol.mockReset();
invalidateSABnzbdService();
});
it('fails connection when API key is missing', async () => {
const service = new SABnzbdService('http://sab', '');
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('API key is required');
expect(clientMock.get).not.toHaveBeenCalled();
});
it('returns a friendly error for invalid API key', async () => {
clientMock.get.mockResolvedValueOnce({
data: { status: false, error: 'API Key Incorrect' },
});
const service = new SABnzbdService('http://sab', 'bad-key');
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid API key');
expect(clientMock.get).toHaveBeenCalledTimes(1);
});
it('returns non-API key errors from the server', async () => {
clientMock.get.mockResolvedValueOnce({
data: { status: false, error: 'No permissions' },
});
const service = new SABnzbdService('http://sab', 'bad-key');
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toBe('No permissions');
});
it('returns version when connection succeeds', async () => {
clientMock.get
.mockResolvedValueOnce({ data: { status: true } })
.mockResolvedValueOnce({ data: { version: '4.0.0' } });
const service = new SABnzbdService('http://sab', 'good-key');
const result = await service.testConnection();
expect(result.success).toBe(true);
expect(result.version).toBe('4.0.0');
expect(clientMock.get).toHaveBeenCalledTimes(2);
});
it('returns SSL error message when certificate issues occur', async () => {
clientMock.get.mockRejectedValueOnce(new Error('certificate error'));
const service = new SABnzbdService('https://sab', 'key');
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('SSL/TLS certificate error');
});
it('returns a friendly error on connection refused', async () => {
clientMock.get.mockRejectedValueOnce(new Error('ECONNREFUSED'));
const service = new SABnzbdService('http://sab', 'key');
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Connection refused');
});
it('adds NZB with mapped priority', async () => {
// Mock getConfig for ensureCategory (called before adding NZB)
clientMock.get
.mockResolvedValueOnce({
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { books: { dir: '' } } } },
});
// 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', {
category: 'books',
priority: 'high',
});
expect(nzbId).toBe('nzb-1');
expect(clientMock.post).toHaveBeenCalledTimes(1);
});
it('adds NZB with force priority', async () => {
// Mock getConfig for ensureCategory (called before adding NZB)
clientMock.get
.mockResolvedValueOnce({
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } },
});
// 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');
const nzbId = await service.addNZB('https://example.com/book.nzb', { priority: 'force' });
expect(nzbId).toBe('nzb-9');
expect(clientMock.post).toHaveBeenCalledTimes(1);
});
it('returns queue item info when NZB is active', async () => {
clientMock.get.mockResolvedValueOnce({
data: {
queue: {
slots: [
{
nzo_id: 'nzb-2',
filename: 'Queue Book',
mb: '10',
mbleft: '5',
percentage: '50',
status: 'Paused',
timeleft: '0:00:10',
cat: 'readmeabook',
priority: 'Normal',
},
],
},
},
});
const service = new SABnzbdService('http://sab', 'key');
const info = await service.getNZB('nzb-2');
expect(info?.nzbId).toBe('nzb-2');
expect(info?.progress).toBe(0.5);
expect(info?.status).toBe('paused');
expect(info?.size).toBe(10 * 1024 * 1024);
expect(info?.timeLeft).toBe(10);
});
it('maps queue slots from getQueue', async () => {
clientMock.get.mockResolvedValueOnce({
data: {
queue: {
slots: [
{
nzo_id: 'nzb-10',
filename: 'Queue Book',
mb: '5',
mbleft: '2',
percentage: '40',
status: 'Queued',
timeleft: '0:01:00',
cat: 'readmeabook',
priority: 'Normal',
},
],
},
},
});
const service = new SABnzbdService('http://sab', 'key');
const queue = await service.getQueue();
expect(queue[0]).toEqual(expect.objectContaining({
nzbId: 'nzb-10',
name: 'Queue Book',
size: 5,
sizeLeft: 2,
percentage: 40,
status: 'Queued',
}));
});
it('maps history slots from getHistory', async () => {
clientMock.get.mockResolvedValueOnce({
data: {
history: {
slots: [
{
nzo_id: 'nzb-11',
name: 'History Book',
category: 'readmeabook',
status: 'Failed',
bytes: '1024',
fail_message: 'Failed',
storage: '/downloads',
completed: '1700000001',
download_time: '60',
},
],
},
},
});
const service = new SABnzbdService('http://sab', 'key');
const history = await service.getHistory(1);
expect(history[0]).toEqual(expect.objectContaining({
nzbId: 'nzb-11',
status: 'Failed',
bytes: '1024',
failMessage: 'Failed',
}));
});
it('returns history item info when NZB has completed', async () => {
clientMock.get
.mockResolvedValueOnce({ data: { queue: { slots: [] } } })
.mockResolvedValueOnce({
data: {
history: {
slots: [
{
nzo_id: 'nzb-3',
name: 'History Book',
category: 'readmeabook',
status: 'Completed',
bytes: '2048',
fail_message: '',
storage: '/downloads/book',
completed: '1700000000',
download_time: '60',
},
],
},
},
});
const service = new SABnzbdService('http://sab', 'key');
const info = await service.getNZB('nzb-3');
expect(info?.nzbId).toBe('nzb-3');
expect(info?.progress).toBe(1);
expect(info?.status).toBe('completed');
expect(info?.downloadPath).toBe('/downloads/book');
expect(info?.completedAt?.getTime()).toBe(1700000000 * 1000);
});
it('returns history item info when NZB has failed', async () => {
clientMock.get
.mockResolvedValueOnce({ data: { queue: { slots: [] } } })
.mockResolvedValueOnce({
data: {
history: {
slots: [
{
nzo_id: 'nzb-12',
name: 'Failed Book',
category: 'readmeabook',
status: 'Failed',
bytes: '2048',
fail_message: 'Bad nzb',
storage: '/downloads/book',
completed: '1700000002',
download_time: '30',
},
],
},
},
});
const service = new SABnzbdService('http://sab', 'key');
const info = await service.getNZB('nzb-12');
expect(info?.status).toBe('failed');
expect(info?.errorMessage).toBe('Bad nzb');
});
it('maps repairing status in download progress', () => {
const service = new SABnzbdService('http://sab', 'key');
const progress = service.getDownloadProgress({
nzbId: 'nzb-4',
name: 'Repairing Book',
size: 1,
sizeLeft: 1,
percentage: 100,
status: 'Repairing',
timeLeft: '0:00:00',
category: 'readmeabook',
priority: 'Normal',
});
expect(progress.state).toBe('repairing');
expect(progress.percent).toBe(1);
});
it('maps queued and extracting status in download progress', () => {
const service = new SABnzbdService('http://sab', 'key');
const queued = service.getDownloadProgress({
nzbId: 'nzb-5',
name: 'Queued Book',
size: 2,
sizeLeft: 2,
percentage: 0,
status: 'Queued',
timeLeft: '0:10:00',
category: 'readmeabook',
priority: 'Normal',
});
const extracting = service.getDownloadProgress({
nzbId: 'nzb-6',
name: 'Extracting Book',
size: 2,
sizeLeft: 1,
percentage: 50,
status: 'Extracting',
timeLeft: '0:05:00',
category: 'readmeabook',
priority: 'Normal',
});
expect(queued.state).toBe('queued');
expect(extracting.state).toBe('extracting');
});
it('maps completed status when percentage is 100', () => {
const service = new SABnzbdService('http://sab', 'key');
const progress = service.getDownloadProgress({
nzbId: 'nzb-7',
name: 'Done Book',
size: 1,
sizeLeft: 0,
percentage: 100,
status: 'Downloading',
timeLeft: '0:00:00',
category: 'readmeabook',
priority: 'Normal',
});
expect(progress.state).toBe('completed');
expect(progress.percent).toBe(1);
});
it('creates the default category when missing', async () => {
clientMock.get
.mockResolvedValueOnce({
data: { config: { version: '1', misc: { complete_dir: '/mnt/usenet/complete' }, categories: {} } },
})
.mockResolvedValueOnce({ data: { status: true } });
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
await service.ensureCategory();
expect(clientMock.get).toHaveBeenCalledWith('/api', expect.objectContaining({
params: expect.objectContaining({ mode: 'set_config', keyword: 'readmeabook' }),
}));
});
it('swallows errors when ensuring categories fails', async () => {
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
const configSpy = vi.spyOn(service, 'getConfig').mockRejectedValue(new Error('bad config'));
await expect(service.ensureCategory()).resolves.toBeUndefined();
configSpy.mockRestore();
});
it('does not create category when it already exists with correct path', async () => {
clientMock.get.mockResolvedValueOnce({
data: {
config: {
version: '1',
misc: { complete_dir: '/mnt/usenet/complete' },
categories: { readmeabook: { dir: '/downloads' } },
},
},
});
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
await service.ensureCategory();
// Only get_config called, no set_config because path already matches
expect(clientMock.get).toHaveBeenCalledTimes(1);
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 upload failure
clientMock.get
.mockResolvedValueOnce({
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } },
});
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');
await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('Bad NZB');
});
it('throws when SABnzbd returns no NZB IDs', async () => {
// Mock getConfig for ensureCategory, then the upload with empty IDs
clientMock.get
.mockResolvedValueOnce({
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } },
});
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');
await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('did not return an NZB ID');
});
it('returns null when NZB is not found in queue or history', async () => {
clientMock.get
.mockResolvedValueOnce({ data: { queue: { slots: [] } } })
.mockResolvedValueOnce({ data: { history: { slots: [] } } });
const service = new SABnzbdService('http://sab', 'key');
const info = await service.getNZB('missing');
expect(info).toBeNull();
});
it('returns an error message for connection timeouts', async () => {
clientMock.get.mockRejectedValueOnce(new Error('ETIMEDOUT'));
const service = new SABnzbdService('http://sab', 'key');
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('timed out');
});
it('throws when version is missing from response', async () => {
clientMock.get.mockResolvedValueOnce({ data: {} });
const service = new SABnzbdService('http://sab', 'key');
await expect(service.getVersion()).rejects.toThrow('Failed to get SABnzbd version');
});
it('throws when config payload is missing', async () => {
clientMock.get.mockResolvedValueOnce({ data: {} });
const service = new SABnzbdService('http://sab', 'key');
await expect(service.getConfig()).rejects.toThrow('Failed to get SABnzbd configuration');
});
it('creates a singleton service from config', async () => {
// Mock: SABnzbd client configured via DownloadClientManager
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'sabnzbd',
name: 'SABnzbd',
enabled: true,
url: 'http://sab',
password: 'api-key', // API key stored in password field
disableSSLVerify: false,
remotePathMappingEnabled: false,
category: 'books',
});
configServiceMock.get.mockResolvedValue('/downloads');
const ensureSpy = vi.spyOn(SABnzbdService.prototype, 'ensureCategory').mockResolvedValue();
const service = await getSABnzbdService();
const again = await getSABnzbdService();
expect(service).toBe(again);
expect(ensureSpy).toHaveBeenCalled();
ensureSpy.mockRestore();
});
it('creates singleton with path mapping config when enabled', async () => {
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'sabnzbd',
name: 'SABnzbd',
enabled: true,
url: 'http://sab',
password: 'api-key',
disableSSLVerify: false,
remotePathMappingEnabled: true,
remotePath: '/mnt/usenet/complete',
localPath: '/downloads',
category: 'readmeabook',
});
configServiceMock.get.mockResolvedValue('/downloads');
const ensureSpy = vi.spyOn(SABnzbdService.prototype, 'ensureCategory').mockResolvedValue();
const service = await getSABnzbdService();
expect(service).toBeDefined();
expect(ensureSpy).toHaveBeenCalled();
ensureSpy.mockRestore();
});
describe('Path Mapping', () => {
it('uses empty category path when download_dir matches complete_dir', async () => {
clientMock.get
.mockResolvedValueOnce({
data: {
config: {
version: '1',
misc: { complete_dir: '/downloads' },
categories: {},
},
},
})
.mockResolvedValueOnce({ data: { status: true } });
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
await service.ensureCategory();
// Should set empty dir when paths match
const setCategoryCall = clientMock.get.mock.calls.find(
(call) => call[1]?.params?.mode === 'set_config'
);
expect(setCategoryCall).toBeDefined();
expect(setCategoryCall![1].params.dir).toBe('');
});
it('uses relative path when download_dir is under complete_dir', async () => {
clientMock.get
.mockResolvedValueOnce({
data: {
config: {
version: '1',
misc: { complete_dir: '/mnt/usenet/complete' },
categories: {},
},
},
})
.mockResolvedValueOnce({ data: { status: true } });
const service = new SABnzbdService(
'http://sab',
'key',
'readmeabook',
'/mnt/usenet/complete/audiobooks'
);
await service.ensureCategory();
const setCategoryCall = clientMock.get.mock.calls.find(
(call) => call[1]?.params?.mode === 'set_config'
);
expect(setCategoryCall).toBeDefined();
expect(setCategoryCall![1].params.dir).toBe('audiobooks');
});
it('uses absolute path when download_dir differs from complete_dir', async () => {
clientMock.get
.mockResolvedValueOnce({
data: {
config: {
version: '1',
misc: { complete_dir: '/mnt/usenet/complete' },
categories: {},
},
},
})
.mockResolvedValueOnce({ data: { status: true } });
const service = new SABnzbdService(
'http://sab',
'key',
'readmeabook',
'/different/path/audiobooks'
);
await service.ensureCategory();
const setCategoryCall = clientMock.get.mock.calls.find(
(call) => call[1]?.params?.mode === 'set_config'
);
expect(setCategoryCall).toBeDefined();
expect(setCategoryCall![1].params.dir).toBe('/different/path/audiobooks');
});
it('applies reverse path mapping before comparing with complete_dir', async () => {
clientMock.get
.mockResolvedValueOnce({
data: {
config: {
version: '1',
misc: { complete_dir: '/mnt/usenet/complete' },
categories: {},
},
},
})
.mockResolvedValueOnce({ data: { status: true } });
// RMAB sees /downloads but SABnzbd sees /mnt/usenet/complete
const pathMappingConfig = {
enabled: true,
remotePath: '/mnt/usenet/complete',
localPath: '/downloads',
};
const service = new SABnzbdService(
'http://sab',
'key',
'readmeabook',
'/downloads', // RMAB's local path
false,
pathMappingConfig
);
await service.ensureCategory();
// After reverse transform, /downloads becomes /mnt/usenet/complete
// which matches complete_dir, so category dir should be empty
const setCategoryCall = clientMock.get.mock.calls.find(
(call) => call[1]?.params?.mode === 'set_config'
);
expect(setCategoryCall).toBeDefined();
expect(setCategoryCall![1].params.dir).toBe('');
});
it('updates category path when it differs from calculated path', async () => {
clientMock.get
.mockResolvedValueOnce({
data: {
config: {
version: '1',
misc: { complete_dir: '/mnt/usenet/complete' },
categories: { readmeabook: { dir: '/old/path' } },
},
},
})
.mockResolvedValueOnce({ data: { status: true } });
const service = new SABnzbdService(
'http://sab',
'key',
'readmeabook',
'/mnt/usenet/complete/audiobooks'
);
await service.ensureCategory();
// Should update the category with new relative path
const setCategoryCall = clientMock.get.mock.calls.find(
(call) => call[1]?.params?.mode === 'set_config'
);
expect(setCategoryCall).toBeDefined();
expect(setCategoryCall![1].params.dir).toBe('audiobooks');
});
it('fetches complete_dir from SABnzbd config', async () => {
clientMock.get.mockResolvedValueOnce({
data: {
config: {
version: '4.0.0',
misc: { complete_dir: '/mnt/usenet/complete' },
categories: { test: { dir: 'test-dir' } },
},
},
});
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
const config = await service.getConfig();
expect(config.completeDir).toBe('/mnt/usenet/complete');
expect(config.categories).toEqual([{ name: 'test', dir: 'test-dir' }]);
});
it('returns complete_dir via getCompleteDir helper', async () => {
clientMock.get.mockResolvedValueOnce({
data: {
config: {
version: '4.0.0',
misc: { complete_dir: '/var/usenet/done' },
categories: {},
},
},
});
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
const completeDir = await service.getCompleteDir();
expect(completeDir).toBe('/var/usenet/done');
});
it('handles missing complete_dir gracefully', async () => {
clientMock.get
.mockResolvedValueOnce({
data: {
config: {
version: '4.0.0',
misc: {}, // No complete_dir
categories: {},
},
},
})
.mockResolvedValueOnce({ data: { status: true } });
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
await service.ensureCategory();
// Should fallback to using download_dir directly
const setCategoryCall = clientMock.get.mock.calls.find(
(call) => call[1]?.params?.mode === 'set_config'
);
expect(setCategoryCall).toBeDefined();
expect(setCategoryCall![1].params.dir).toBe('/downloads');
});
it('handles Windows-style paths in path mapping', async () => {
clientMock.get
.mockResolvedValueOnce({
data: {
config: {
version: '1',
misc: { complete_dir: 'D:\\Usenet\\Complete' },
categories: {},
},
},
})
.mockResolvedValueOnce({ data: { status: true } });
const pathMappingConfig = {
enabled: true,
remotePath: 'D:\\Usenet\\Complete',
localPath: '/downloads',
};
const service = new SABnzbdService(
'http://sab',
'key',
'readmeabook',
'/downloads',
false,
pathMappingConfig
);
await service.ensureCategory();
// After reverse transform and comparison (normalized), should match
const setCategoryCall = clientMock.get.mock.calls.find(
(call) => call[1]?.params?.mode === 'set_config'
);
expect(setCategoryCall).toBeDefined();
// Path should be empty since /downloads maps to D:\Usenet\Complete which matches complete_dir
expect(setCategoryCall![1].params.dir).toBe('');
});
});
});