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.
445 lines
13 KiB
TypeScript
445 lines
13 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(),
|
|
}));
|
|
|
|
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;
|
|
}
|
|
});
|
|
});
|