Files
ReadMeABook/tests/integrations/sabnzbd.service.test.ts
T
kikootwo 94dbaf073b Add backend unit test framework and modularize settings UI
Introduced a Vitest-based backend unit testing framework with supporting scripts, helpers, and GitHub Actions integration. Refactored the admin settings page to a modular architecture, splitting monolithic logic into feature-specific tabs and hooks for improved maintainability and testability. Updated documentation to reflect the new testing setup and settings architecture, and added new dependencies for testing utilities.
2026-01-28 11:41:59 -05:00

487 lines
14 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(),
}));
const axiosMock = vi.hoisted(() => ({
create: vi.fn(() => clientMock),
}));
const configServiceMock = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock('axios', () => ({
default: axiosMock,
...axiosMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
describe('SABnzbdService', () => {
beforeEach(() => {
vi.clearAllMocks();
clientMock.get.mockReset();
configServiceMock.get.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.error).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.error).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.error).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.error).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.error).toContain('Connection refused');
});
it('adds NZB with mapped priority', async () => {
clientMock.get.mockResolvedValueOnce({
data: { status: true, nzo_ids: ['nzb-1'] },
});
const service = new SABnzbdService('http://sab', 'key');
const nzbId = await service.addNZB('https://example.com/book.nzb', {
category: 'books',
priority: 'high',
});
const params = clientMock.get.mock.calls[0][1].params;
expect(nzbId).toBe('nzb-1');
expect(params.cat).toBe('books');
expect(params.priority).toBe('1');
});
it('adds NZB with force priority', async () => {
clientMock.get.mockResolvedValueOnce({
data: { status: true, nzo_ids: ['nzb-9'] },
});
const service = new SABnzbdService('http://sab', 'key');
await service.addNZB('https://example.com/book.nzb', { priority: 'force' });
const params = clientMock.get.mock.calls[0][1].params;
expect(params.priority).toBe('2');
});
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', categories: {} } },
})
.mockResolvedValueOnce({ data: { status: true } });
const service = new SABnzbdService('http://sab', 'key', 'readmeabook');
await service.ensureCategory('/downloads');
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');
const configSpy = vi.spyOn(service, 'getConfig').mockRejectedValue(new Error('bad config'));
await expect(service.ensureCategory('/downloads')).resolves.toBeUndefined();
configSpy.mockRestore();
});
it('does not create category when it already exists', async () => {
clientMock.get.mockResolvedValueOnce({
data: {
config: {
version: '1',
categories: { readmeabook: { dir: '/downloads' } },
},
},
});
const service = new SABnzbdService('http://sab', 'key', 'readmeabook');
await service.ensureCategory('/downloads');
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 () => {
clientMock.get.mockResolvedValueOnce({
data: { status: false, error: 'Bad NZB' },
});
const service = new SABnzbdService('http://sab', 'key');
await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('Bad NZB');
});
it('throws when SABnzbd returns no NZB IDs', async () => {
clientMock.get.mockResolvedValueOnce({
data: { status: true, nzo_ids: [] },
});
const service = new SABnzbdService('http://sab', 'key');
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.error).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 () => {
configServiceMock.get.mockImplementation(async (key: string) => {
switch (key) {
case 'download_client_url':
return 'http://sab';
case 'download_client_password':
return 'api-key';
case 'sabnzbd_category':
return 'books';
case 'download_client_disable_ssl_verify':
return 'false';
case 'download_dir':
return '/downloads';
default:
return null;
}
});
const ensureSpy = vi.spyOn(SABnzbdService.prototype, 'ensureCategory').mockResolvedValue();
const service = await getSABnzbdService();
const again = await getSABnzbdService();
expect(service).toBe(again);
expect(ensureSpy).toHaveBeenCalledWith('/downloads');
ensureSpy.mockRestore();
});
});