mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
2cda6decbe
Implements support for configuring both qBittorrent and SABnzbd simultaneously, including migration from legacy config, protocol-aware routing, and protocol filtering. Adds new CRUD API routes for download clients, new UI management components, and updates setup and settings flows to use the new multi-client architecture. Updates documentation to describe the new structure and usage.
471 lines
14 KiB
TypeScript
471 lines
14 KiB
TypeScript
/**
|
|
* 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(),
|
|
}));
|
|
|
|
// 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: () => configMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
|
getDownloadClientManager: () => downloadClientManagerMock,
|
|
invalidateDownloadClientManager: vi.fn(),
|
|
}));
|
|
|
|
describe('ProwlarrService', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
clientMock.get.mockReset();
|
|
axiosMock.get.mockReset();
|
|
configMock.get.mockReset();
|
|
downloadClientManagerMock.getClientForProtocol.mockReset();
|
|
downloadClientManagerMock.getAllClients.mockReset();
|
|
downloadClientManagerMock.hasClientForProtocol.mockReset();
|
|
});
|
|
|
|
it('filters results for SABnzbd (usenet)', async () => {
|
|
// Mock: Only SABnzbd is configured (usenet only)
|
|
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
|
|
return protocol === 'usenet';
|
|
});
|
|
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 () => {
|
|
// Mock: qBittorrent is configured (torrent only)
|
|
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
|
|
return protocol === 'torrent';
|
|
});
|
|
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 () => {
|
|
// Mock: Only qBittorrent is configured (torrent only)
|
|
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
|
|
return protocol === 'torrent';
|
|
});
|
|
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 () => {
|
|
// Mock: Only qBittorrent is configured (torrent only)
|
|
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
|
|
return protocol === 'torrent';
|
|
});
|
|
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 () => {
|
|
// Mock: hasClientForProtocol throws an error
|
|
downloadClientManagerMock.hasClientForProtocol.mockRejectedValue(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;
|
|
}
|
|
});
|
|
});
|