mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
94dbaf073b
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.
487 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|