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.
This commit is contained in:
kikootwo
2026-01-15 16:49:59 -05:00
parent b3f89d67bb
commit 94dbaf073b
127 changed files with 23549 additions and 2868 deletions
+444
View File
@@ -0,0 +1,444 @@
/**
* Component: Prowlarr Integration Service Tests
* Documentation: documentation/phase3/prowlarr.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
const clientMock = vi.hoisted(() => ({
get: vi.fn(),
interceptors: {
request: {
use: vi.fn(),
},
},
}));
const axiosMock = vi.hoisted(() => ({
create: vi.fn(() => clientMock),
get: vi.fn(),
}));
const configMock = vi.hoisted(() => ({
get: vi.fn(),
getMany: vi.fn(),
}));
vi.mock('axios', () => ({
default: axiosMock,
...axiosMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configMock,
}));
describe('ProwlarrService', () => {
beforeEach(() => {
vi.clearAllMocks();
clientMock.get.mockReset();
axiosMock.get.mockReset();
configMock.get.mockReset();
});
it('filters results for SABnzbd (usenet)', async () => {
configMock.get.mockResolvedValue('sabnzbd');
clientMock.get.mockResolvedValue({
data: [
{
guid: 'g1',
indexer: 'IndexerA',
title: 'Book NZB',
size: 100,
publishDate: '2024-01-01T00:00:00.000Z',
downloadUrl: 'https://example.com/book.nzb',
protocol: 'usenet',
},
{
guid: 'g2',
indexer: 'IndexerB',
title: 'Book Torrent',
size: 200,
publishDate: '2024-01-02T00:00:00.000Z',
magnetUrl: 'magnet:?xt=urn:btih:abc',
protocol: 'torrent',
},
],
});
const service = new ProwlarrService('http://prowlarr', 'key');
const results = await service.search('Book');
expect(results).toHaveLength(1);
expect(results[0].downloadUrl).toContain('.nzb');
expect(results[0].protocol).toBe('usenet');
});
it('throws when search fails', async () => {
configMock.get.mockResolvedValue('qbittorrent');
clientMock.get.mockRejectedValue(new Error('bad search'));
const service = new ProwlarrService('http://prowlarr', 'key');
await expect(service.search('Book')).rejects.toThrow('Failed to search Prowlarr: bad search');
});
it('filters results for qBittorrent (torrent)', async () => {
configMock.get.mockResolvedValue('qbittorrent');
clientMock.get.mockResolvedValue({
data: [
{
guid: 'g1',
indexer: 'IndexerA',
title: 'Book NZB',
size: 100,
publishDate: '2024-01-01T00:00:00.000Z',
downloadUrl: 'https://example.com/book.nzb',
protocol: 'usenet',
},
{
guid: 'g2',
indexer: 'IndexerB',
title: 'Book Torrent',
size: 200,
publishDate: '2024-01-02T00:00:00.000Z',
magnetUrl: 'magnet:?xt=urn:btih:abc',
protocol: 'torrent',
},
],
});
const service = new ProwlarrService('http://prowlarr', 'key');
const results = await service.search('Book');
expect(results).toHaveLength(1);
expect(results[0].downloadUrl).toContain('magnet:?');
expect(results[0].protocol).toBe('torrent');
});
it('parses RSS feeds into torrent results', async () => {
const xml = `
<rss xmlns:torznab="http://torznab.com/schemas/2015/feed">
<channel>
<item>
<title>Great Book M4B 64kbps</title>
<link>https://example.com/book.torrent</link>
<guid>guid-1</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
<prowlarrindexer>IndexerA</prowlarrindexer>
<torznab:attr name="seeders" value="5" />
<torznab:attr name="peers" value="8" />
<torznab:attr name="infohash" value="HASH" />
</item>
</channel>
</rss>
`;
axiosMock.get.mockResolvedValue({ data: xml });
const service = new ProwlarrService('http://prowlarr', 'key');
const results = await service.getRssFeed(1);
expect(results).toHaveLength(1);
expect(results[0].seeders).toBe(5);
expect(results[0].leechers).toBe(3);
expect(results[0].format).toBe('M4B');
expect(results[0].bitrate).toBe('64kbps');
expect(results[0].hasChapters).toBe(true);
});
it('skips RSS items missing download URLs', async () => {
const xml = `
<rss xmlns:torznab="http://torznab.com/schemas/2015/feed">
<channel>
<item>
<title>Book Without Link</title>
<guid>guid-2</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
<prowlarrindexer>IndexerA</prowlarrindexer>
</item>
</channel>
</rss>
`;
axiosMock.get.mockResolvedValue({ data: xml });
const service = new ProwlarrService('http://prowlarr', 'key');
const results = await service.getRssFeed(2);
expect(results).toHaveLength(0);
});
it('detects NZB downloads by protocol or URL', () => {
expect(ProwlarrService.isNZBResult({ downloadUrl: 'https://x/test.nzb' } as any)).toBe(true);
expect(ProwlarrService.isNZBResult({ downloadUrl: 'https://x/getnzb?id=1' } as any)).toBe(true);
expect(ProwlarrService.isNZBResult({ downloadUrl: 'magnet:?xt=urn:btih:abc' } as any)).toBe(false);
expect(ProwlarrService.isNZBResult({ downloadUrl: 'https://x/file', protocol: 'usenet' } as any)).toBe(true);
});
it('applies category, indexer, and seeder filters', async () => {
configMock.get.mockResolvedValue('qbittorrent');
clientMock.get.mockResolvedValue({
data: [
{
guid: 'g1',
indexer: 'IndexerA',
title: 'Book One',
size: 100,
publishDate: '2024-01-01T00:00:00.000Z',
downloadUrl: 'https://example.com/book.torrent',
protocol: 'torrent',
seeders: 1,
},
{
guid: 'g2',
indexer: 'IndexerB',
title: 'Book Two',
size: 200,
publishDate: '2024-01-02T00:00:00.000Z',
downloadUrl: 'https://example.com/book2.torrent',
protocol: 'torrent',
seeders: 10,
},
],
});
const service = new ProwlarrService('http://prowlarr', 'key');
const results = await service.search('Book', {
categories: [3030, 3040],
minSeeders: 2,
maxResults: 1,
indexerIds: [1, 2],
});
expect(results).toHaveLength(1);
expect(results[0].title).toBe('Book Two');
expect(clientMock.get).toHaveBeenCalledWith('/search', {
params: expect.objectContaining({
categories: [3030, 3040],
indexerIds: [1, 2],
}),
});
});
it('returns unfiltered results when protocol filtering fails', async () => {
configMock.get
.mockResolvedValueOnce('qbittorrent')
.mockRejectedValueOnce(new Error('config fail'));
clientMock.get.mockResolvedValue({
data: [
{
guid: 'g1',
indexer: 'IndexerA',
title: 'Book NZB',
size: 100,
publishDate: '2024-01-01T00:00:00.000Z',
downloadUrl: 'https://example.com/book.nzb',
protocol: 'usenet',
},
{
guid: 'g2',
indexer: 'IndexerB',
title: 'Book Torrent',
size: 200,
publishDate: '2024-01-02T00:00:00.000Z',
downloadUrl: 'https://example.com/book.torrent',
protocol: 'torrent',
},
],
});
const service = new ProwlarrService('http://prowlarr', 'key');
const results = await service.search('Book');
expect(results).toHaveLength(2);
});
it('aggregates RSS feeds and ignores failures', async () => {
const service = new ProwlarrService('http://prowlarr', 'key');
const rssSpy = vi.spyOn(service, 'getRssFeed')
.mockRejectedValueOnce(new Error('bad'))
.mockResolvedValueOnce([{ guid: 'g1' } as any]);
const results = await service.getAllRssFeeds([1, 2]);
expect(rssSpy).toHaveBeenCalledTimes(2);
expect(results).toHaveLength(1);
});
it('skips results without download URLs', () => {
const service = new ProwlarrService('http://prowlarr', 'key');
const result = (service as any).transformResult({
guid: 'g1',
indexer: 'IndexerA',
title: 'No URL',
size: 100,
publishDate: '2024-01-01T00:00:00.000Z',
});
expect(result).toBeNull();
});
it('extracts flags from indexer fields and title metadata', () => {
const service = new ProwlarrService('http://prowlarr', 'key');
const result = (service as any).transformResult({
guid: 'g3',
indexer: 'IndexerA',
title: 'Book M4A 128kbps',
size: 100,
publishDate: '2024-01-01T00:00:00.000Z',
downloadUrl: 'https://example.com/book.torrent',
indexerFlags: ['Trusted', 2],
flags: ['Featured', 'Trusted'],
});
expect(result?.flags).toEqual(['Trusted', 'Featured']);
expect(result?.format).toBe('M4A');
expect(result?.bitrate).toBe('128kbps');
});
it('derives flags from volume factors when no explicit flags exist', () => {
const service = new ProwlarrService('http://prowlarr', 'key');
const result = (service as any).transformResult({
guid: 'g4',
indexer: 'IndexerB',
title: 'Book MP3',
size: 100,
publishDate: '2024-01-01T00:00:00.000Z',
downloadUrl: 'https://example.com/book.torrent',
downloadVolumeFactor: 0,
uploadVolumeFactor: 2,
});
expect(result?.flags).toEqual(['Freeleech', 'Double Upload']);
expect(result?.format).toBe('MP3');
});
it('marks partial freeleech when download volume factor is reduced', () => {
const service = new ProwlarrService('http://prowlarr', 'key');
const result = (service as any).transformResult({
guid: 'g5',
indexer: 'IndexerC',
title: 'Book MP3',
size: 100,
publishDate: '2024-01-01T00:00:00.000Z',
downloadUrl: 'https://example.com/book.torrent',
downloadVolumeFactor: 0.5,
});
expect(result?.flags).toEqual(['Partial Freeleech']);
});
it('returns null when transformResult throws', () => {
const service = new ProwlarrService('http://prowlarr', 'key');
const result = (service as any).transformResult({
guid: 'g6',
indexer: 'IndexerD',
title: null,
size: 100,
publishDate: '2024-01-01T00:00:00.000Z',
downloadUrl: 'https://example.com/book.torrent',
});
expect(result).toBeNull();
});
it('returns indexers and stats', async () => {
clientMock.get
.mockResolvedValueOnce({ data: [{ id: 1, name: 'IndexerA' }] })
.mockResolvedValueOnce({ data: { indexers: [] } });
const service = new ProwlarrService('http://prowlarr', 'key');
const indexers = await service.getIndexers();
const stats = await service.getStats();
expect(indexers).toHaveLength(1);
expect(stats.indexers).toEqual([]);
});
it('returns false when connection test fails', async () => {
clientMock.get.mockRejectedValue(new Error('health down'));
const service = new ProwlarrService('http://prowlarr', 'key');
const ok = await service.testConnection();
expect(ok).toBe(false);
});
it('throws when indexer stats cannot be fetched', async () => {
clientMock.get.mockRejectedValue(new Error('no stats'));
const service = new ProwlarrService('http://prowlarr', 'key');
await expect(service.getStats()).rejects.toThrow('Failed to get indexer statistics');
});
it('returns a singleton service from configuration', async () => {
const originalApiKey = process.env.PROWLARR_API_KEY;
delete process.env.PROWLARR_API_KEY;
vi.resetModules();
configMock.getMany.mockResolvedValue({
prowlarr_url: 'http://prowlarr',
prowlarr_api_key: 'api-key',
});
clientMock.get.mockResolvedValue({ data: {} });
const { getProwlarrService } = await import('@/lib/integrations/prowlarr.service');
const serviceA = await getProwlarrService();
const serviceB = await getProwlarrService();
expect(serviceA).toBe(serviceB);
if (originalApiKey === undefined) {
delete process.env.PROWLARR_API_KEY;
} else {
process.env.PROWLARR_API_KEY = originalApiKey;
}
});
it('throws when Prowlarr API key is missing', async () => {
const originalApiKey = process.env.PROWLARR_API_KEY;
delete process.env.PROWLARR_API_KEY;
vi.resetModules();
configMock.getMany.mockResolvedValue({
prowlarr_url: 'http://prowlarr',
prowlarr_api_key: '',
});
const { getProwlarrService } = await import('@/lib/integrations/prowlarr.service');
await expect(getProwlarrService()).rejects.toThrow('Prowlarr API key not configured');
if (originalApiKey === undefined) {
delete process.env.PROWLARR_API_KEY;
} else {
process.env.PROWLARR_API_KEY = originalApiKey;
}
});
it('returns service even when connection test fails', async () => {
const originalApiKey = process.env.PROWLARR_API_KEY;
delete process.env.PROWLARR_API_KEY;
vi.resetModules();
configMock.getMany.mockResolvedValue({
prowlarr_url: 'http://prowlarr',
prowlarr_api_key: 'api-key',
});
clientMock.get.mockRejectedValue(new Error('health down'));
const { getProwlarrService } = await import('@/lib/integrations/prowlarr.service');
const service = await getProwlarrService();
expect(service).toBeDefined();
if (originalApiKey === undefined) {
delete process.env.PROWLARR_API_KEY;
} else {
process.env.PROWLARR_API_KEY = originalApiKey;
}
});
});