mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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:
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* Component: Audible Integration Service Tests
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AudibleService } from '@/lib/integrations/audible.service';
|
||||
import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '@/lib/types/audible';
|
||||
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const axiosMock = vi.hoisted(() => ({
|
||||
create: vi.fn(() => clientMock),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getAudibleRegion: vi.fn(),
|
||||
}));
|
||||
|
||||
const fsCoreMock = vi.hoisted(() => ({
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => fsCoreMock);
|
||||
|
||||
describe('AudibleService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clientMock.get.mockReset();
|
||||
axiosMock.get.mockReset();
|
||||
configServiceMock.getAudibleRegion.mockReset();
|
||||
});
|
||||
|
||||
const buildListHtml = (count: number, startIndex: number = 0) =>
|
||||
Array.from({ length: count }, (_, i) => {
|
||||
const asin = `B${String(i + 1 + startIndex).padStart(9, '0')}`;
|
||||
return `
|
||||
<div class="productListItem">
|
||||
<li data-asin="${asin}"></li>
|
||||
<h3><a>Title ${i + 1}</a></h3>
|
||||
<span class="authorLabel">By: Author ${i + 1}</span>
|
||||
<span class="narratorLabel">Narrated by: Narrator ${i + 1}</span>
|
||||
<img src="https://images-na.ssl-images-amazon.com/images/I/abc._SL200_.jpg" />
|
||||
<span class="ratingsLabel">4.${i} out of 5 stars</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
it('parses search results from HTML', async () => {
|
||||
const html = `
|
||||
<div class="s-result-item">
|
||||
<li data-asin="B000123456"></li>
|
||||
<h2>The Test Book</h2>
|
||||
<a href="/author/Author-Name">Author Name</a>
|
||||
<span class="narratorLabel">Narrated by: Narrator Name</span>
|
||||
<img src="https://images-na.ssl-images-amazon.com/images/I/abc._SL200_.jpg" />
|
||||
<span class="runtimeLabel">Length: 5 hrs and 30 mins</span>
|
||||
<span class="ratingsLabel">4.5 out of 5 stars</span>
|
||||
</div>
|
||||
<div class="resultsInfo">1-20 of 55 results</div>
|
||||
`;
|
||||
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const result = await service.search('test', 1);
|
||||
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0].asin).toBe('B000123456');
|
||||
expect(result.results[0].title).toBe('The Test Book');
|
||||
expect(result.results[0].author).toBe('Author Name');
|
||||
expect(result.results[0].narrator).toBe('Narrator Name');
|
||||
expect(result.results[0].durationMinutes).toBe(330);
|
||||
expect(result.results[0].rating).toBe(4.5);
|
||||
expect(result.results[0].coverArtUrl).toContain('_SL500_');
|
||||
expect(result.totalResults).toBe(55);
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('reinitializes when the configured region changes', async () => {
|
||||
const html = `<div class="resultsInfo">0 results</div>`;
|
||||
configServiceMock.getAudibleRegion
|
||||
.mockResolvedValueOnce('us')
|
||||
.mockResolvedValueOnce('uk')
|
||||
.mockResolvedValueOnce('uk');
|
||||
clientMock.get.mockResolvedValue({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
await service.search('test', 1);
|
||||
await service.search('test', 1);
|
||||
|
||||
expect(axiosMock.create).toHaveBeenCalledTimes(2);
|
||||
expect(axiosMock.create.mock.calls[1][0].baseURL).toBe(AUDIBLE_REGIONS.uk.baseUrl);
|
||||
});
|
||||
|
||||
it('reinitializes when forced manually', async () => {
|
||||
const html = `<div class="resultsInfo">0 results</div>`;
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockResolvedValue({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
await service.search('test', 1);
|
||||
service.forceReinitialize();
|
||||
await service.search('test', 1);
|
||||
|
||||
expect(axiosMock.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('falls back to default region when initialization fails', async () => {
|
||||
const html = `<div class="resultsInfo">0 results</div>`;
|
||||
configServiceMock.getAudibleRegion.mockRejectedValue(new Error('config fail'));
|
||||
clientMock.get.mockResolvedValue({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const result = await service.search('fallback', 1);
|
||||
|
||||
expect(result.totalResults).toBe(0);
|
||||
expect(axiosMock.create.mock.calls[0][0].baseURL).toBe(AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION].baseUrl);
|
||||
});
|
||||
|
||||
it('paginates new releases and respects delays between pages', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({ data: buildListHtml(10, 0) })
|
||||
.mockResolvedValueOnce({ data: buildListHtml(5, 10) });
|
||||
|
||||
const service = new AudibleService();
|
||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||
const results = await service.getNewReleases(25);
|
||||
|
||||
expect(results).toHaveLength(15);
|
||||
expect(delaySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('parses popular audiobooks and stops early when fewer results are found', async () => {
|
||||
const html = `
|
||||
<div class="productListItem">
|
||||
<li data-asin="B000111111"></li>
|
||||
<h3><a>Popular One</a></h3>
|
||||
<span class="authorLabel">By: Author One</span>
|
||||
<span class="narratorLabel">Narrated by: Narrator One</span>
|
||||
<img src="https://images-na.ssl-images-amazon.com/images/I/abc._SL200_.jpg" />
|
||||
<span class="ratingsLabel">4.2 out of 5 stars</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const results = await service.getPopularAudiobooks(1);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].asin).toBe('B000111111');
|
||||
expect(results[0].title).toBe('Popular One');
|
||||
});
|
||||
|
||||
it('skips duplicate ASINs when parsing new releases', async () => {
|
||||
const html = `
|
||||
<div class="productListItem">
|
||||
<li data-asin="B000222222"></li>
|
||||
<h3><a>Title One</a></h3>
|
||||
</div>
|
||||
<div class="productListItem">
|
||||
<li data-asin="B000222222"></li>
|
||||
<h3><a>Title Two</a></h3>
|
||||
</div>
|
||||
`;
|
||||
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const results = await service.getNewReleases(20);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].title).toBe('Title One');
|
||||
});
|
||||
|
||||
it('returns empty search results on failures', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockRejectedValue(new Error('nope'));
|
||||
|
||||
const service = new AudibleService();
|
||||
const result = await service.search('oops', 1);
|
||||
|
||||
expect(result.results).toEqual([]);
|
||||
expect(result.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('returns audiobooks from Audnexus when available', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
title: 'Audnexus Book',
|
||||
authors: [{ name: 'Author A' }],
|
||||
narrators: [{ name: 'Narrator A' }],
|
||||
description: 'Desc',
|
||||
image: 'https://images.example.com/cover._SL200_.jpg',
|
||||
runtimeLengthMin: '300',
|
||||
genres: ['Fiction'],
|
||||
rating: '4.7',
|
||||
},
|
||||
});
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000AAAAAA');
|
||||
|
||||
expect(details?.title).toBe('Audnexus Book');
|
||||
expect(details?.author).toBe('Author A');
|
||||
expect(details?.durationMinutes).toBe(300);
|
||||
expect(details?.coverArtUrl).toContain('_SL500_');
|
||||
});
|
||||
|
||||
it('scrapes details from HTML when Audnexus fails', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 500 }, message: 'boom' });
|
||||
|
||||
const html = `
|
||||
<script type="application/ld+json">{invalid}</script>
|
||||
<div class="product-top-section">
|
||||
<h1 class="bc-heading">HTML Title</h1>
|
||||
<li class="authorLabel"><a>By: HTML Author</a></li>
|
||||
<li class="narratorLabel"><a>Narrated by: HTML Narrator</a></li>
|
||||
<li class="runtimeLabel"><span>Length: 2 hrs and 5 mins</span></li>
|
||||
<li>Release date: Jan 2, 2022</li>
|
||||
<span class="ratingsLabel">4.8 out of 5 stars</span>
|
||||
<img class="bc-image-inset-border" src="https://images.example.com/cover._SL200_.jpg" />
|
||||
<div class="bc-expander-content">
|
||||
This is a long description for testing the Audible HTML parsing logic.
|
||||
</div>
|
||||
<a href="/cat/fiction">Fiction</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000CCCCCC');
|
||||
|
||||
expect(details?.title).toBe('HTML Title');
|
||||
expect(details?.author).toBe('HTML Author');
|
||||
expect(details?.narrator).toBe('HTML Narrator');
|
||||
expect(details?.durationMinutes).toBe(125);
|
||||
expect(details?.rating).toBe(4.8);
|
||||
expect(details?.releaseDate).toBe('Jan 2, 2022');
|
||||
expect(details?.coverArtUrl).toContain('_SL500_');
|
||||
expect(details?.genres).toContain('Fiction');
|
||||
});
|
||||
|
||||
it('falls back to Audible scraping when Audnexus returns 404', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });
|
||||
|
||||
const html = `
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@type": "Product",
|
||||
"name": "Fallback Book",
|
||||
"author": {"name": "Fallback Author"},
|
||||
"readBy": {"name": "Fallback Narrator"},
|
||||
"description": "A long description that exceeds fifty characters for validation.",
|
||||
"image": "https://images.example.com/cover._SL200_.jpg",
|
||||
"aggregateRating": { "ratingValue": "4.6" },
|
||||
"datePublished": "Jan 1, 2024",
|
||||
"duration": "PT8H30M"
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000BBBBBB');
|
||||
|
||||
expect(details?.title).toBe('Fallback Book');
|
||||
expect(details?.author).toBe('Fallback Author');
|
||||
expect(details?.durationMinutes).toBe(510);
|
||||
});
|
||||
|
||||
it('returns runtime from Audnexus data', async () => {
|
||||
axiosMock.get.mockResolvedValue({ data: { runtimeLengthMin: '480' } });
|
||||
|
||||
const service = new AudibleService();
|
||||
const runtime = await service.getRuntime('B000123456');
|
||||
|
||||
expect(runtime).toBe(480);
|
||||
});
|
||||
|
||||
it('returns null runtime when Audnexus returns 404', async () => {
|
||||
axiosMock.get.mockRejectedValue({ response: { status: 404 }, message: 'Not found' });
|
||||
|
||||
const service = new AudibleService();
|
||||
const runtime = await service.getRuntime('B000404404');
|
||||
|
||||
expect(runtime).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null runtime when Audnexus errors unexpectedly', async () => {
|
||||
axiosMock.get.mockRejectedValue({ response: { status: 500 }, message: 'Boom' });
|
||||
|
||||
const service = new AudibleService();
|
||||
const runtime = await service.getRuntime('B000500500');
|
||||
|
||||
expect(runtime).toBeNull();
|
||||
});
|
||||
|
||||
it('parses runtime strings into minutes', () => {
|
||||
const service = new AudibleService();
|
||||
const parseRuntime = (service as any).parseRuntime.bind(service);
|
||||
|
||||
expect(parseRuntime('Length: 1 hr and 5 mins')).toBe(65);
|
||||
expect(parseRuntime('Length: 45 mins')).toBe(45);
|
||||
expect(parseRuntime('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not reinitialize when the region is unchanged', async () => {
|
||||
const html = `<div class="resultsInfo">0 results</div>`;
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockResolvedValue({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
await service.search('test', 1);
|
||||
await service.search('test', 1);
|
||||
|
||||
expect(axiosMock.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('paginates popular audiobooks across pages', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({ data: buildListHtml(10, 0) })
|
||||
.mockResolvedValueOnce({ data: buildListHtml(10, 10) });
|
||||
|
||||
const service = new AudibleService();
|
||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||
const results = await service.getPopularAudiobooks(25);
|
||||
|
||||
expect(results).toHaveLength(20);
|
||||
expect(delaySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns empty popular audiobooks on errors', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const service = new AudibleService();
|
||||
const results = await service.getPopularAudiobooks(5);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty new releases on errors', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const service = new AudibleService();
|
||||
const results = await service.getNewReleases(5);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null when getAudiobookDetails throws', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
|
||||
const service = new AudibleService();
|
||||
vi.spyOn(service as any, 'fetchFromAudnexus').mockResolvedValue(null);
|
||||
vi.spyOn(service as any, 'scrapeAudibleDetails').mockRejectedValue(new Error('boom'));
|
||||
|
||||
const result = await service.getAudiobookDetails('B000TEST');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('writes debug HTML in development mode', async () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });
|
||||
clientMock.get.mockResolvedValueOnce({
|
||||
data: '<div class="product-top-section"><h1 class="bc-heading">Dev Book</h1></div>',
|
||||
});
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000DEV');
|
||||
|
||||
expect(details?.title).toBe('Dev Book');
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
it('parses JSON-LD author and narrator arrays', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });
|
||||
|
||||
const html = `
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@type": "Product",
|
||||
"name": "Array Book",
|
||||
"author": [{"name": "Author One"}, {"name": "Author Two"}],
|
||||
"readBy": [{"name": "Narrator One"}, {"name": "Narrator Two"}],
|
||||
"description": "A description that is long enough to be accepted in tests.",
|
||||
"image": "https://images.example.com/cover._SL200_.jpg",
|
||||
"duration": "PT1H30M"
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000ARRAY');
|
||||
|
||||
expect(details?.author).toBe('Author One, Author Two');
|
||||
expect(details?.narrator).toBe('Narrator One, Narrator Two');
|
||||
});
|
||||
|
||||
it('falls back to author and narrator links when labels are missing', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });
|
||||
|
||||
const html = `
|
||||
<div class="product-top-section">
|
||||
<a href="/author/Author-One">Author One</a>
|
||||
<a href="/author/See-All">See all</a>
|
||||
<a href="/narrator/Narr-One">Narrator One</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000LINKS');
|
||||
|
||||
expect(details?.author).toBe('Author One');
|
||||
expect(details?.narrator).toBe('Narrator One');
|
||||
});
|
||||
|
||||
it('extracts descriptions from fallback paragraphs', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });
|
||||
|
||||
const html = `
|
||||
<p>This description is intentionally long enough to satisfy the minimum length requirement for parsing.</p>
|
||||
`;
|
||||
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000DESC');
|
||||
|
||||
expect(details?.description).toContain('intentionally long enough');
|
||||
});
|
||||
|
||||
it('detects runtime from generic duration text', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });
|
||||
|
||||
const html = `
|
||||
<span>10 hr 2 min</span>
|
||||
`;
|
||||
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000TIME');
|
||||
|
||||
expect(details?.durationMinutes).toBe(602);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,709 @@
|
||||
/**
|
||||
* Component: Plex Integration Service Tests
|
||||
* Documentation: documentation/integrations/plex.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PlexService } from '@/lib/integrations/plex.service';
|
||||
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
const axiosMock = vi.hoisted(() => ({
|
||||
create: vi.fn(() => clientMock),
|
||||
}));
|
||||
|
||||
const parseStringPromiseMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
vi.mock('xml2js', () => ({
|
||||
parseStringPromise: parseStringPromiseMock,
|
||||
}));
|
||||
|
||||
describe('PlexService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('requests a PIN for OAuth', async () => {
|
||||
clientMock.post.mockResolvedValue({ data: { id: 123, code: 'CODE' } });
|
||||
|
||||
const service = new PlexService();
|
||||
const pin = await service.requestPin();
|
||||
|
||||
expect(pin).toEqual({ id: 123, code: 'CODE' });
|
||||
});
|
||||
|
||||
it('throws when PIN request fails', async () => {
|
||||
clientMock.post.mockRejectedValue(new Error('fail'));
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.requestPin()).rejects.toThrow('Failed to request authentication PIN from Plex');
|
||||
});
|
||||
|
||||
it('returns null when PIN check fails', async () => {
|
||||
clientMock.get.mockRejectedValue(new Error('fail'));
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.checkPin(123);
|
||||
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it('returns auth token when PIN is authorized', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: { authToken: 'plex-token' } });
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.checkPin(456);
|
||||
|
||||
expect(token).toBe('plex-token');
|
||||
});
|
||||
|
||||
it('parses user info from XML responses', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
user: {
|
||||
$: { id: '1', username: 'user', email: 'e@example.com', thumb: '/t' },
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const user = await service.getUserInfo('token');
|
||||
|
||||
expect(user).toEqual({
|
||||
id: 1,
|
||||
username: 'user',
|
||||
email: 'e@example.com',
|
||||
thumb: '/t',
|
||||
authToken: 'token',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses user info from JSON responses and falls back to title', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: { id: '2', title: 'TitleUser', email: 't@example.com', thumb: '/t' },
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const user = await service.getUserInfo('token');
|
||||
|
||||
expect(user.username).toBe('TitleUser');
|
||||
expect(user.id).toBe(2);
|
||||
});
|
||||
|
||||
it('throws for unexpected XML user structure', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({ notUser: {} });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.getUserInfo('token')).rejects.toThrow('Unexpected XML structure');
|
||||
});
|
||||
|
||||
it('throws for unexpected response formats', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: 42 });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.getUserInfo('token')).rejects.toThrow('Unexpected response format from Plex');
|
||||
});
|
||||
|
||||
it('throws when user info is missing required fields', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: { username: 'user' } });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.getUserInfo('token')).rejects.toThrow('User ID missing');
|
||||
});
|
||||
|
||||
it('throws when username is missing from user info', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: { id: '3' } });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.getUserInfo('token')).rejects.toThrow('Username missing');
|
||||
});
|
||||
|
||||
it('returns OAuth URLs with pinId', () => {
|
||||
const service = new PlexService();
|
||||
const url = service.getOAuthUrl('CODE', 42, 'http://app/callback');
|
||||
|
||||
expect(url).toContain('CODE');
|
||||
expect(url).toContain('pinId%3D42');
|
||||
});
|
||||
|
||||
it('tests connections and parses MediaContainer responses', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
MediaContainer: {
|
||||
machineIdentifier: 'machine',
|
||||
version: '1.0.0',
|
||||
platform: 'Plex',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const result = await service.testConnection('http://plex', 'token');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.info?.machineIdentifier).toBe('machine');
|
||||
});
|
||||
|
||||
it('tests connections from XML identity responses', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
MediaContainer: { $: { machineIdentifier: 'm1', version: '1.2.3', platform: 'Linux', platformVersion: '5' } },
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const result = await service.testConnection('http://plex', 'token');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.info?.platform).toBe('Linux');
|
||||
});
|
||||
|
||||
it('finds server access tokens in plex resources', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{ clientIdentifier: 'machine', accessToken: 'server-token' },
|
||||
],
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.getServerAccessToken('machine', 'user-token');
|
||||
|
||||
expect(token).toBe('server-token');
|
||||
});
|
||||
|
||||
it('returns null when server resource is missing', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: [{ clientIdentifier: 'other', accessToken: 'x' }] });
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.getServerAccessToken('machine', 'user-token');
|
||||
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when server access token is missing', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [{ clientIdentifier: 'machine', accessToken: null }],
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.getServerAccessToken('machine', 'user-token');
|
||||
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it('verifies server access for matching resources', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{ clientIdentifier: 'machine', provides: 'server', name: 'Plex' },
|
||||
],
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const hasAccess = await service.verifyServerAccess('http://plex', 'machine', 'user-token');
|
||||
|
||||
expect(hasAccess).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when server access is not available', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [{ clientIdentifier: 'other', provides: 'client', name: 'Plex' }],
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const hasAccess = await service.verifyServerAccess('http://plex', 'machine', 'user-token');
|
||||
|
||||
expect(hasAccess).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when verifying server access errors', async () => {
|
||||
clientMock.get.mockRejectedValue({ response: { status: 500, data: 'oops' }, message: 'boom' });
|
||||
|
||||
const service = new PlexService();
|
||||
const hasAccess = await service.verifyServerAccess('http://plex', 'machine', 'user-token');
|
||||
|
||||
expect(hasAccess).toBe(false);
|
||||
});
|
||||
|
||||
it('parses libraries from XML responses', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
MediaContainer: {
|
||||
Directory: [
|
||||
{
|
||||
$: {
|
||||
key: '1',
|
||||
title: 'Books',
|
||||
type: 'artist',
|
||||
language: 'en',
|
||||
scanner: 'scanner',
|
||||
agent: 'agent',
|
||||
},
|
||||
Location: [{ $: { path: '/data' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const libs = await service.getLibraries('http://plex', 'token');
|
||||
|
||||
expect(libs).toEqual([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Books',
|
||||
type: 'artist',
|
||||
language: 'en',
|
||||
scanner: 'scanner',
|
||||
agent: 'agent',
|
||||
locations: ['/data'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses libraries from JSON responses', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
MediaContainer: {
|
||||
Directory: [
|
||||
{
|
||||
key: '2',
|
||||
title: 'Library',
|
||||
type: 'artist',
|
||||
language: 'en',
|
||||
scanner: 'scanner',
|
||||
agent: 'agent',
|
||||
Location: [{ path: '/media' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const libs = await service.getLibraries('http://plex', 'token');
|
||||
|
||||
expect(libs).toEqual([
|
||||
{
|
||||
id: '2',
|
||||
title: 'Library',
|
||||
type: 'artist',
|
||||
language: 'en',
|
||||
scanner: 'scanner',
|
||||
agent: 'agent',
|
||||
locations: ['/media'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null metadata for unauthorized users', async () => {
|
||||
clientMock.get.mockRejectedValue({ response: { status: 401 } });
|
||||
|
||||
const service = new PlexService();
|
||||
const meta = await service.getItemMetadata('http://plex', 'token', 'rk-1');
|
||||
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null metadata when item is missing', async () => {
|
||||
clientMock.get.mockRejectedValue({ response: { status: 404 } });
|
||||
|
||||
const service = new PlexService();
|
||||
const meta = await service.getItemMetadata('http://plex', 'token', 'rk-2');
|
||||
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
|
||||
it('parses metadata from XML responses', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
MediaContainer: {
|
||||
Metadata: [{ $: { userRating: '9' } }],
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const meta = await service.getItemMetadata('http://plex', 'token', 'rk-3');
|
||||
|
||||
expect(meta?.userRating).toBe(9);
|
||||
});
|
||||
|
||||
it('returns user ratings when metadata exists', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: { MediaContainer: { Metadata: [{ userRating: '7.5' }] } },
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const meta = await service.getItemMetadata('http://plex', 'token', 'rk-1');
|
||||
|
||||
expect(meta?.userRating).toBe(7.5);
|
||||
});
|
||||
|
||||
it('searches library content from XML responses', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
MediaContainer: {
|
||||
Metadata: [
|
||||
{
|
||||
$: {
|
||||
ratingKey: 'rk-1',
|
||||
guid: 'guid-1',
|
||||
title: 'Title',
|
||||
grandparentTitle: 'Author',
|
||||
summary: 'Summary',
|
||||
thumb: '/thumb',
|
||||
addedAt: '1',
|
||||
updatedAt: '2',
|
||||
duration: '1000',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const results = await service.searchLibrary('http://plex', 'token', 'lib-1', 'Title');
|
||||
|
||||
expect(results[0].ratingKey).toBe('rk-1');
|
||||
expect(results[0].author).toBe('Author');
|
||||
});
|
||||
|
||||
it('returns empty arrays when search fails', async () => {
|
||||
clientMock.get.mockRejectedValue(new Error('search fail'));
|
||||
|
||||
const service = new PlexService();
|
||||
const results = await service.searchLibrary('http://plex', 'token', 'lib-1', 'Title');
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty arrays when recently added data is not a list', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: { MediaContainer: { Metadata: {} } },
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const results = await service.getRecentlyAdded('http://plex', 'token', 'lib-1', 10);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty arrays when library content data is not a list', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: { MediaContainer: { Metadata: {} } },
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const results = await service.getLibraryContent('http://plex', 'token', 'lib-1');
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses library content from XML responses', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
MediaContainer: {
|
||||
Metadata: [
|
||||
{
|
||||
ratingKey: 'rk-1',
|
||||
guid: 'guid-1',
|
||||
title: 'Title',
|
||||
parentTitle: 'Author',
|
||||
writer: 'Narr',
|
||||
duration: '1000',
|
||||
year: '2020',
|
||||
summary: 'Summary',
|
||||
thumb: '/thumb',
|
||||
addedAt: '1',
|
||||
updatedAt: '2',
|
||||
userRating: '7',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const results = await service.getLibraryContent('http://plex', 'token', 'lib-1');
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
ratingKey: 'rk-1',
|
||||
guid: 'guid-1',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
narrator: 'Narr',
|
||||
duration: 1000,
|
||||
year: 2020,
|
||||
summary: 'Summary',
|
||||
thumb: '/thumb',
|
||||
addedAt: 1,
|
||||
updatedAt: 2,
|
||||
userRating: 7,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws when fetching library content fails with 401', async () => {
|
||||
clientMock.get.mockRejectedValue({ response: { status: 401 } });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.getLibraryContent('http://plex', 'token', 'lib-1')).rejects.toThrow(
|
||||
'Failed to retrieve content from Plex library'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns recently added items from JSON responses', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
MediaContainer: {
|
||||
Metadata: [
|
||||
{
|
||||
ratingKey: 'rk-2',
|
||||
guid: 'guid-2',
|
||||
title: 'New Title',
|
||||
parentTitle: 'Author',
|
||||
writer: 'Narrator',
|
||||
duration: '2000',
|
||||
year: '2021',
|
||||
summary: 'Summary',
|
||||
thumb: '/thumb2',
|
||||
addedAt: '3',
|
||||
updatedAt: '4',
|
||||
userRating: '8',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const results = await service.getRecentlyAdded('http://plex', 'token', 'lib-1', 5);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
ratingKey: 'rk-2',
|
||||
guid: 'guid-2',
|
||||
title: 'New Title',
|
||||
author: 'Author',
|
||||
narrator: 'Narrator',
|
||||
duration: 2000,
|
||||
year: 2021,
|
||||
summary: 'Summary',
|
||||
thumb: '/thumb2',
|
||||
addedAt: 3,
|
||||
updatedAt: 4,
|
||||
userRating: 8,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('triggers Plex library scans', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: {} });
|
||||
|
||||
const service = new PlexService();
|
||||
await expect(service.scanLibrary('http://plex', 'token', 'lib-1')).resolves.toBeUndefined();
|
||||
|
||||
expect(clientMock.get).toHaveBeenCalledWith(
|
||||
'http://plex/library/sections/lib-1/refresh',
|
||||
expect.objectContaining({
|
||||
headers: { 'X-Plex-Token': 'token' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when scan triggers fail', async () => {
|
||||
clientMock.get.mockRejectedValue(new Error('scan failed'));
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.scanLibrary('http://plex', 'token', 'lib-1')).rejects.toThrow(
|
||||
'Failed to trigger Plex library scan'
|
||||
);
|
||||
});
|
||||
|
||||
it('collects ratings in batch and skips failures', async () => {
|
||||
const service = new PlexService();
|
||||
const getItemSpy = vi.spyOn(service, 'getItemMetadata')
|
||||
.mockResolvedValueOnce({ userRating: 4 })
|
||||
.mockRejectedValueOnce({ response: { status: 401 } })
|
||||
.mockResolvedValueOnce({ userRating: 3 });
|
||||
|
||||
const ratings = await service.batchGetUserRatings('http://plex', 'token', ['a', 'b', 'c']);
|
||||
|
||||
expect(getItemSpy).toHaveBeenCalledTimes(3);
|
||||
expect(ratings.get('a')).toBe(4);
|
||||
expect(ratings.get('c')).toBe(3);
|
||||
expect(ratings.has('b')).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts home users from API responses', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
MediaContainer: {
|
||||
User: [
|
||||
{
|
||||
$: {
|
||||
id: '1',
|
||||
uuid: 'uuid',
|
||||
title: 'User',
|
||||
username: 'user',
|
||||
email: 'user@example.com',
|
||||
thumb: '/thumb',
|
||||
hasPassword: '1',
|
||||
restricted: '0',
|
||||
admin: '1',
|
||||
guest: '0',
|
||||
protected: '0',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const users = await service.getHomeUsers('token');
|
||||
|
||||
expect(users).toHaveLength(1);
|
||||
expect(users[0].friendlyName).toBe('User');
|
||||
expect(users[0].admin).toBe(true);
|
||||
});
|
||||
|
||||
it('extracts home users from home.users responses', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
home: {
|
||||
users: [
|
||||
{
|
||||
user: {
|
||||
id: '2',
|
||||
uuid: 'uuid-2',
|
||||
title: 'Kid',
|
||||
username: 'kid',
|
||||
email: 'kid@example.com',
|
||||
thumb: '/thumb',
|
||||
hasPassword: 'true',
|
||||
restricted: 'true',
|
||||
admin: 'false',
|
||||
guest: 'false',
|
||||
protected: 'true',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const users = await service.getHomeUsers('token');
|
||||
|
||||
expect(users).toHaveLength(1);
|
||||
expect(users[0].friendlyName).toBe('Kid');
|
||||
expect(users[0].restricted).toBe(true);
|
||||
expect(users[0].protected).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty list when no home users are available', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: {} });
|
||||
|
||||
const service = new PlexService();
|
||||
const users = await service.getHomeUsers('token');
|
||||
|
||||
expect(users).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty list when fetching home users fails', async () => {
|
||||
clientMock.get.mockRejectedValue(new Error('home users down'));
|
||||
|
||||
const service = new PlexService();
|
||||
const users = await service.getHomeUsers('token');
|
||||
|
||||
expect(users).toEqual([]);
|
||||
});
|
||||
|
||||
it('switches home users and returns profile token', async () => {
|
||||
clientMock.post.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
user: { $: { authenticationToken: 'profile-token' } },
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.switchHomeUser('user-1', 'token');
|
||||
|
||||
expect(token).toBe('profile-token');
|
||||
});
|
||||
|
||||
it('returns null when switch response has no auth token', async () => {
|
||||
clientMock.post.mockResolvedValue({ data: { user: { name: 'NoToken' } } });
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.switchHomeUser('user-2', 'token');
|
||||
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it('returns token from direct switch responses', async () => {
|
||||
clientMock.post.mockResolvedValue({ data: { authenticationToken: 'token-1' } });
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.switchHomeUser('user-4', 'token');
|
||||
|
||||
expect(token).toBe('token-1');
|
||||
});
|
||||
|
||||
it('returns token when authenticationToken is nested under user', async () => {
|
||||
clientMock.post.mockResolvedValue({ data: { user: { authenticationToken: 'token-2' } } });
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.switchHomeUser('user-5', 'token');
|
||||
|
||||
expect(token).toBe('token-2');
|
||||
});
|
||||
|
||||
it('returns token when authenticationToken is on root attributes', async () => {
|
||||
clientMock.post.mockResolvedValue({ data: { $: { authenticationToken: 'token-3' } } });
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.switchHomeUser('user-6', 'token');
|
||||
|
||||
expect(token).toBe('token-3');
|
||||
});
|
||||
|
||||
it('throws when switching home user with invalid PIN', async () => {
|
||||
clientMock.post.mockRejectedValue({ response: { status: 401 } });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.switchHomeUser('user-3', 'token', '1234')).rejects.toThrow('Invalid PIN');
|
||||
});
|
||||
|
||||
it('throws when switching home user fails for non-auth errors', async () => {
|
||||
clientMock.post.mockRejectedValue({ response: { status: 500 }, message: 'boom' });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.switchHomeUser('user-9', 'token')).rejects.toThrow(
|
||||
'Failed to switch to selected profile'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a singleton instance from getPlexService', async () => {
|
||||
const { getPlexService } = await import('@/lib/integrations/plex.service');
|
||||
const serviceA = getPlexService();
|
||||
const serviceB = getPlexService();
|
||||
|
||||
expect(serviceA).toBe(serviceB);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* Component: qBittorrent Integration Service Tests
|
||||
* Documentation: documentation/phase3/qbittorrent.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { QBittorrentService, getQBittorrentService, invalidateQBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
const axiosMock = vi.hoisted(() => ({
|
||||
create: vi.fn(() => clientMock),
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
isAxiosError: (error: any) => Boolean(error?.isAxiosError),
|
||||
}));
|
||||
|
||||
const parseTorrentMock = vi.hoisted(() => vi.fn());
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
vi.mock('parse-torrent', () => ({
|
||||
default: parseTorrentMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
describe('QBittorrentService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clientMock.get.mockReset();
|
||||
clientMock.post.mockReset();
|
||||
axiosMock.get.mockReset();
|
||||
axiosMock.post.mockReset();
|
||||
parseTorrentMock.mockReset();
|
||||
configServiceMock.getMany.mockReset();
|
||||
invalidateQBittorrentService();
|
||||
});
|
||||
|
||||
it('maps download progress from torrent info', () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const progress = service.getDownloadProgress({
|
||||
progress: 0.42,
|
||||
downloaded: 420,
|
||||
size: 1000,
|
||||
dlspeed: 50,
|
||||
eta: 120,
|
||||
state: 'pausedDL',
|
||||
} as any);
|
||||
|
||||
expect(progress.percent).toBe(42);
|
||||
expect(progress.bytesDownloaded).toBe(420);
|
||||
expect(progress.bytesTotal).toBe(1000);
|
||||
expect(progress.speed).toBe(50);
|
||||
expect(progress.eta).toBe(120);
|
||||
expect(progress.state).toBe('paused');
|
||||
});
|
||||
|
||||
it('extracts info hash from magnet links', () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const hash = (service as any).extractHashFromMagnet(
|
||||
'magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567'
|
||||
);
|
||||
|
||||
expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
|
||||
expect((service as any).extractHashFromMagnet('magnet:?xt=urn:btih:')).toBeNull();
|
||||
});
|
||||
|
||||
it('maps allocating state to downloading', () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const progress = service.getDownloadProgress({
|
||||
progress: 0.1,
|
||||
downloaded: 100,
|
||||
size: 1000,
|
||||
dlspeed: 0,
|
||||
eta: 0,
|
||||
state: 'allocating' as any,
|
||||
} as any);
|
||||
|
||||
expect(progress.state).toBe('downloading');
|
||||
});
|
||||
|
||||
it('authenticates and stores a session cookie', async () => {
|
||||
axiosMock.post.mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: 'Ok.',
|
||||
headers: { 'set-cookie': ['SID=abc; Path=/;'] },
|
||||
});
|
||||
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
await service.login();
|
||||
|
||||
expect((service as any).cookie).toBe('SID=abc');
|
||||
});
|
||||
|
||||
it('throws when login response lacks a cookie', async () => {
|
||||
axiosMock.post.mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: 'Ok.',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
|
||||
await expect(service.login()).rejects.toThrow('Failed to authenticate with qBittorrent');
|
||||
});
|
||||
|
||||
it('rejects empty torrent URLs', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
|
||||
await expect(service.addTorrent('')).rejects.toThrow('Invalid download URL');
|
||||
});
|
||||
|
||||
it('skips adding duplicate magnet links', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=dup';
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
vi.spyOn(service as any, 'getTorrent').mockResolvedValue({ hash: 'existing' });
|
||||
|
||||
const hash = await service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567');
|
||||
|
||||
expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
|
||||
expect(clientMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds magnet links when not already present', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=add';
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
||||
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||
|
||||
const hash = await service.addTorrent(
|
||||
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||
{ tags: ['tag1', 'tag2'] }
|
||||
);
|
||||
|
||||
expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/add',
|
||||
expect.any(URLSearchParams),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when magnet link is invalid', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=badmagnet';
|
||||
|
||||
await expect(
|
||||
(service as any).addMagnetLink('magnet:?xt=urn:btih:', 'readmeabook')
|
||||
).rejects.toThrow('Invalid magnet link');
|
||||
});
|
||||
|
||||
it('throws when qBittorrent rejects magnet uploads', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=rejected';
|
||||
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
||||
clientMock.post.mockResolvedValue({ data: 'Nope' });
|
||||
|
||||
await expect(
|
||||
(service as any).addMagnetLink(
|
||||
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||
'readmeabook'
|
||||
)
|
||||
).rejects.toThrow('qBittorrent rejected magnet link');
|
||||
});
|
||||
|
||||
it('re-authenticates after a 403 and retries adding torrents', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=old';
|
||||
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
const loginSpy = vi.spyOn(service, 'login').mockResolvedValue();
|
||||
const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink')
|
||||
.mockRejectedValueOnce({ isAxiosError: true, response: { status: 403 } })
|
||||
.mockResolvedValueOnce('rehash');
|
||||
|
||||
const hash = await service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567');
|
||||
|
||||
expect(hash).toBe('rehash');
|
||||
expect(loginSpy).toHaveBeenCalledTimes(1);
|
||||
expect(addMagnetSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('follows redirect to magnet link when downloading torrent files', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=redir';
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink').mockResolvedValue('redirect-hash');
|
||||
|
||||
axiosMock.get.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 302, headers: { location: 'magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01' } },
|
||||
});
|
||||
|
||||
const hash = await service.addTorrent('http://example.com/file.torrent');
|
||||
|
||||
expect(hash).toBe('redirect-hash');
|
||||
expect(addMagnetSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats magnet response bodies as magnet links', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=body';
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink').mockResolvedValue('body-hash');
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: Buffer.from('magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01'),
|
||||
});
|
||||
|
||||
const hash = await service.addTorrent('http://example.com/file.torrent');
|
||||
|
||||
expect(hash).toBe('body-hash');
|
||||
expect(addMagnetSpy).toHaveBeenCalled();
|
||||
expect(parseTorrentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds torrent files after parsing successfully', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=ok';
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
|
||||
parseTorrentMock.mockResolvedValueOnce({ infoHash: 'hash-1', name: 'Book' });
|
||||
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||
|
||||
const hash = await service.addTorrent('http://example.com/file.torrent');
|
||||
|
||||
expect(hash).toBe('hash-1');
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/add',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ maxBodyLength: Infinity })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for invalid redirect locations when fetching torrents', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
|
||||
axiosMock.get.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 302, headers: { location: 'ftp://bad' } },
|
||||
message: 'redirect',
|
||||
});
|
||||
|
||||
await expect(
|
||||
(service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
|
||||
).rejects.toThrow('Invalid redirect location');
|
||||
});
|
||||
|
||||
it('throws when torrent file parsing fails directly', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
|
||||
parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent'));
|
||||
|
||||
await expect(
|
||||
(service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
|
||||
).rejects.toThrow('Invalid .torrent file - failed to parse');
|
||||
});
|
||||
|
||||
it('throws when torrent file has no info hash', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
|
||||
parseTorrentMock.mockResolvedValueOnce({ infoHash: null });
|
||||
|
||||
await expect(
|
||||
(service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
|
||||
).rejects.toThrow('Failed to extract info_hash');
|
||||
});
|
||||
|
||||
it('throws when qBittorrent rejects torrent file uploads', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=reject';
|
||||
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
|
||||
parseTorrentMock.mockResolvedValueOnce({ infoHash: 'hash-2', name: 'Book' });
|
||||
clientMock.post.mockResolvedValue({ data: 'Nope' });
|
||||
|
||||
await expect(
|
||||
(service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
|
||||
).rejects.toThrow('qBittorrent rejected .torrent file');
|
||||
});
|
||||
|
||||
it('throws when torrent parsing fails', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=parse';
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('not-a-torrent') });
|
||||
parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent'));
|
||||
|
||||
await expect(service.addTorrent('http://example.com/file.torrent')).rejects.toThrow(
|
||||
'Failed to add torrent to qBittorrent'
|
||||
);
|
||||
});
|
||||
|
||||
it('creates categories when missing', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads');
|
||||
(service as any).cookie = 'SID=newcat';
|
||||
clientMock.get.mockResolvedValue({ data: {} });
|
||||
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||
|
||||
await (service as any).ensureCategory('readmeabook');
|
||||
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/createCategory',
|
||||
expect.any(URLSearchParams),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when ensuring categories fails', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=catfail';
|
||||
clientMock.get.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 500 },
|
||||
});
|
||||
|
||||
await expect((service as any).ensureCategory('readmeabook')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates category when save path mismatches', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads');
|
||||
(service as any).cookie = 'SID=cat';
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
readmeabook: { savePath: '/old' },
|
||||
},
|
||||
});
|
||||
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||
|
||||
await (service as any).ensureCategory('readmeabook');
|
||||
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/editCategory',
|
||||
expect.any(URLSearchParams),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not update category when save path matches', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads');
|
||||
(service as any).cookie = 'SID=cat-ok';
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
readmeabook: { savePath: '/downloads' },
|
||||
},
|
||||
});
|
||||
|
||||
await (service as any).ensureCategory('readmeabook');
|
||||
|
||||
expect(clientMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pauses and resumes torrents', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=pause';
|
||||
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||
|
||||
await service.pauseTorrent('hash-1');
|
||||
await service.resumeTorrent('hash-1');
|
||||
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/pause',
|
||||
expect.any(URLSearchParams),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/resume',
|
||||
expect.any(URLSearchParams),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when torrent state updates fail', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=fail';
|
||||
clientMock.post.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(service.pauseTorrent('hash-1')).rejects.toThrow('Failed to pause torrent');
|
||||
await expect(service.resumeTorrent('hash-1')).rejects.toThrow('Failed to resume torrent');
|
||||
await expect(service.deleteTorrent('hash-1', false)).rejects.toThrow('Failed to delete torrent');
|
||||
await expect(service.setCategory('hash-1', 'books')).rejects.toThrow('Failed to set torrent category');
|
||||
});
|
||||
|
||||
it('sets categories, deletes torrents, and fetches files', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=ops';
|
||||
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||
clientMock.get.mockResolvedValue({ data: [{ name: 'file1' }] });
|
||||
|
||||
await service.setCategory('hash-1', 'books');
|
||||
await service.deleteTorrent('hash-1', true);
|
||||
const files = await service.getFiles('hash-1');
|
||||
|
||||
expect(files).toEqual([{ name: 'file1' }]);
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/setCategory',
|
||||
expect.any(URLSearchParams),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/delete',
|
||||
expect.any(URLSearchParams),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when fetching torrent files fails', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=files';
|
||||
clientMock.get.mockRejectedValue(new Error('no files'));
|
||||
|
||||
await expect(service.getFiles('hash-1')).rejects.toThrow('Failed to get torrent files');
|
||||
});
|
||||
|
||||
it('throws when torrent is not found', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=missing';
|
||||
clientMock.get.mockResolvedValueOnce({ data: [] });
|
||||
|
||||
await expect(service.getTorrent('hash-404')).rejects.toThrow('Torrent hash-404 not found');
|
||||
});
|
||||
|
||||
it('returns error when getTorrents fails', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=list';
|
||||
clientMock.get.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(service.getTorrents()).rejects.toThrow('Failed to get torrents from qBittorrent');
|
||||
});
|
||||
|
||||
it('returns torrent lists with a category filter', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=list';
|
||||
clientMock.get.mockResolvedValueOnce({ data: [{ hash: 'h1' }] });
|
||||
|
||||
const torrents = await service.getTorrents('books');
|
||||
|
||||
expect(torrents).toEqual([{ hash: 'h1' }]);
|
||||
expect(clientMock.get).toHaveBeenCalledWith(
|
||||
'/torrents/info',
|
||||
expect.objectContaining({ params: { category: 'books' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns unknown state for unrecognized torrent states', () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const progress = service.getDownloadProgress({
|
||||
progress: 0,
|
||||
downloaded: 0,
|
||||
size: 1,
|
||||
dlspeed: 0,
|
||||
eta: 0,
|
||||
state: 'weird' as any,
|
||||
} as any);
|
||||
|
||||
expect(progress.state).toBe('unknown');
|
||||
});
|
||||
|
||||
it('throws specific errors for invalid credentials in testConnectionWithCredentials', async () => {
|
||||
axiosMock.post.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: 'Ok.',
|
||||
headers: { 'set-cookie': ['SID=abc; Path=/;'] },
|
||||
});
|
||||
axiosMock.get.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 401 },
|
||||
config: { url: 'http://qb/api/v2/app/version' },
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
|
||||
await expect(
|
||||
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'bad')
|
||||
).rejects.toThrow('Authentication failed');
|
||||
});
|
||||
|
||||
it('returns version on successful credential test', async () => {
|
||||
axiosMock.post.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: 'Ok.',
|
||||
headers: { 'set-cookie': ['SID=abc; Path=/;'] },
|
||||
});
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: 'v4.6.0',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const version = await QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass');
|
||||
|
||||
expect(version).toBe('v4.6.0');
|
||||
});
|
||||
|
||||
it('throws when test connection receives no cookies', async () => {
|
||||
axiosMock.post.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: 'Ok.',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
|
||||
).rejects.toThrow('Failed to authenticate - no session cookie received');
|
||||
});
|
||||
|
||||
it('throws SSL-specific errors for certificate failures', async () => {
|
||||
axiosMock.post.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
code: 'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
message: 'self signed',
|
||||
config: { url: 'https://qb/api/v2/auth/login' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
QBittorrentService.testConnectionWithCredentials('https://qb', 'user', 'pass', true)
|
||||
).rejects.toThrow('SSL certificate verification failed');
|
||||
});
|
||||
|
||||
it('throws when connection is refused', async () => {
|
||||
axiosMock.post.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
code: 'ECONNREFUSED',
|
||||
message: 'refused',
|
||||
config: { url: 'http://qb/api/v2/auth/login' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
|
||||
).rejects.toThrow('Connection refused');
|
||||
});
|
||||
|
||||
it('throws when server returns 404', async () => {
|
||||
axiosMock.post.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 404 },
|
||||
message: 'Not found',
|
||||
config: { url: 'http://qb/api/v2/auth/login' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
|
||||
).rejects.toThrow('qBittorrent Web UI not found');
|
||||
});
|
||||
|
||||
it('throws on qBittorrent server errors', async () => {
|
||||
axiosMock.post.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 503 },
|
||||
message: 'Server error',
|
||||
config: { url: 'http://qb/api/v2/auth/login' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
|
||||
).rejects.toThrow('qBittorrent server error');
|
||||
});
|
||||
|
||||
it('throws when qBittorrent configuration is incomplete', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
download_client_url: null,
|
||||
download_client_username: null,
|
||||
download_client_password: null,
|
||||
download_dir: null,
|
||||
download_client_disable_ssl_verify: 'false',
|
||||
});
|
||||
|
||||
await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not fully configured');
|
||||
});
|
||||
|
||||
it('returns a cached instance after successful initialization', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
download_client_url: 'http://qb',
|
||||
download_client_username: 'user',
|
||||
download_client_password: 'pass',
|
||||
download_dir: '/downloads',
|
||||
download_client_disable_ssl_verify: 'false',
|
||||
});
|
||||
|
||||
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(true);
|
||||
|
||||
const first = await getQBittorrentService();
|
||||
const second = await getQBittorrentService();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(configServiceMock.getMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
testConnectionSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('throws when connection test fails during service creation', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
download_client_url: 'http://qb',
|
||||
download_client_username: 'user',
|
||||
download_client_password: 'pass',
|
||||
download_dir: '/downloads',
|
||||
download_client_disable_ssl_verify: 'false',
|
||||
});
|
||||
|
||||
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(false);
|
||||
|
||||
await expect(getQBittorrentService()).rejects.toThrow('qBittorrent connection test failed');
|
||||
|
||||
testConnectionSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns false when connection test fails', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const loginSpy = vi.spyOn(service, 'login').mockRejectedValue(new Error('bad auth'));
|
||||
|
||||
const ok = await service.testConnection();
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(loginSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns true when connection test succeeds', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const loginSpy = vi.spyOn(service, 'login').mockResolvedValue();
|
||||
|
||||
const ok = await service.testConnection();
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(loginSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user