mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
20c8fb0898
Introduce user-reported-issues and Goodreads shelf sync features and wire them into notifications. Adds Prisma migrations and schema changes (ReportedIssue, GoodreadsShelf, GoodreadsBookMapping), API endpoints for reporting (POST /audiobooks/[asin]/report-issue) and admin management (list, resolve/dismiss, replace), and an admin UI section to view/dismiss/replace reported issues. Adds a new notification event (issue_reported) with updates to notification schemas, docs and provider handling, plus a notification-events constants file. Refactors request creation to use createRequestForUser service, adds a Goodreads sync processor/service/hooks/UI modals, a scrape-resilience util, and related tests and minor integration updates.
986 lines
36 KiB
TypeScript
986 lines
36 KiB
TypeScript
/**
|
|
* 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(),
|
|
get: vi.fn(),
|
|
}));
|
|
|
|
// Mock for DownloadClientManager
|
|
const downloadClientManagerMock = vi.hoisted(() => ({
|
|
getClientForProtocol: vi.fn(),
|
|
getAllClients: vi.fn(),
|
|
hasClientForProtocol: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('axios', () => ({
|
|
default: axiosMock,
|
|
...axiosMock,
|
|
}));
|
|
|
|
vi.mock('parse-torrent', () => ({
|
|
default: parseTorrentMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/config.service', () => ({
|
|
getConfigService: vi.fn(async () => configServiceMock),
|
|
}));
|
|
|
|
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
|
getDownloadClientManager: () => downloadClientManagerMock,
|
|
invalidateDownloadClientManager: vi.fn(),
|
|
}));
|
|
|
|
describe('QBittorrentService', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
clientMock.get.mockReset();
|
|
clientMock.post.mockReset();
|
|
axiosMock.get.mockReset();
|
|
axiosMock.post.mockReset();
|
|
parseTorrentMock.mockReset();
|
|
configServiceMock.getMany.mockReset();
|
|
configServiceMock.get.mockReset();
|
|
downloadClientManagerMock.getClientForProtocol.mockReset();
|
|
downloadClientManagerMock.getAllClients.mockReset();
|
|
downloadClientManagerMock.hasClientForProtocol.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);
|
|
|
|
expect(progress.state).toBe('downloading');
|
|
});
|
|
|
|
describe('mapState - forced states (Force Resume in qBittorrent UI)', () => {
|
|
it('maps forcedDL to downloading', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0.5, downloaded: 500, size: 1000, dlspeed: 100, eta: 50, state: 'forcedDL',
|
|
} as any);
|
|
expect(progress.state).toBe('downloading');
|
|
});
|
|
|
|
it('maps forcedUP to completed', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'forcedUP',
|
|
} as any);
|
|
expect(progress.state).toBe('completed');
|
|
});
|
|
});
|
|
|
|
describe('mapState - metadata fetching states', () => {
|
|
it('maps metaDL to downloading', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0, downloaded: 0, size: 0, dlspeed: 0, eta: 0, state: 'metaDL',
|
|
} as any);
|
|
expect(progress.state).toBe('downloading');
|
|
});
|
|
|
|
it('maps forcedMetaDL to downloading', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0, downloaded: 0, size: 0, dlspeed: 0, eta: 0, state: 'forcedMetaDL',
|
|
} as any);
|
|
expect(progress.state).toBe('downloading');
|
|
});
|
|
});
|
|
|
|
describe('mapState - qBittorrent v5.x stopped states', () => {
|
|
it('maps stoppedDL to paused', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0.3, downloaded: 300, size: 1000, dlspeed: 0, eta: 0, state: 'stoppedDL',
|
|
} as any);
|
|
expect(progress.state).toBe('paused');
|
|
});
|
|
|
|
it('maps stoppedUP to completed (download finished, stopped on upload side)', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'stoppedUP',
|
|
} as any);
|
|
expect(progress.state).toBe('completed');
|
|
});
|
|
});
|
|
|
|
describe('mapState - other states', () => {
|
|
it('maps checkingResumeData to checking', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0, downloaded: 0, size: 1000, dlspeed: 0, eta: 0, state: 'checkingResumeData',
|
|
} as any);
|
|
expect(progress.state).toBe('checking');
|
|
});
|
|
|
|
it('maps moving to downloading', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'moving',
|
|
} as any);
|
|
expect(progress.state).toBe('downloading');
|
|
});
|
|
});
|
|
|
|
describe('mapState - pausedUP/stoppedUP as completion states (RDT-Client compatibility)', () => {
|
|
it('maps pausedUP to completed (download finished, paused on upload side)', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0.5, downloaded: 0, size: 0, dlspeed: 0, eta: 0, state: 'pausedUP',
|
|
} as any);
|
|
expect(progress.state).toBe('completed');
|
|
});
|
|
|
|
it('maps pausedDL to paused (download not finished)', () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
const progress = service.getDownloadProgress({
|
|
progress: 0.3, downloaded: 300, size: 1000, dlspeed: 0, eta: 0, state: 'pausedDL',
|
|
} as any);
|
|
expect(progress.state).toBe('paused');
|
|
});
|
|
});
|
|
|
|
describe('mapStateToDownloadStatus - forced and new states via getDownload', () => {
|
|
it('maps forcedUP to seeding status (triggers completion in monitor)', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=forced';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
|
dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500,
|
|
eta: 0, state: 'forcedUP', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads', content_path: '/downloads/Audiobook',
|
|
completion_on: 1700000000, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('seeding');
|
|
});
|
|
|
|
it('maps forcedDL to downloading status', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=forced';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.5,
|
|
dlspeed: 1000, upspeed: 0, downloaded: 500, uploaded: 0,
|
|
eta: 500, state: 'forcedDL', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads', completion_on: 0, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('downloading');
|
|
});
|
|
|
|
it('maps stoppedUP to seeding status (qBittorrent v5.x, triggers completion)', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=stopped';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
|
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 200,
|
|
eta: 0, state: 'stoppedUP', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads', completion_on: 1700000000, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('seeding');
|
|
});
|
|
|
|
it('maps pausedUP to seeding status (RDT-Client: download finished, paused on upload side)', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=pausedup';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'd5d767f07e5d9027f7f9d9b50b877386dc92b177', name: 'Audiobook', size: 0, progress: 0.5,
|
|
dlspeed: 0, upspeed: 0, downloaded: 0, uploaded: 0,
|
|
eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
|
|
save_path: '/data/torrents/readmeabook', content_path: '/data/torrents/readmeabook/Audiobook',
|
|
completion_on: 1769135244, added_on: 1769135108,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('d5d767f07e5d9027f7f9d9b50b877386dc92b177');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('seeding');
|
|
});
|
|
|
|
it('maps stoppedDL to paused status (qBittorrent v5.x)', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=stopped';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.3,
|
|
dlspeed: 0, upspeed: 0, downloaded: 300, uploaded: 0,
|
|
eta: 0, state: 'stoppedDL', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads', completion_on: 0, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('paused');
|
|
});
|
|
|
|
it('maps metaDL to downloading status', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=meta';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 0, progress: 0,
|
|
dlspeed: 0, upspeed: 0, downloaded: 0, uploaded: 0,
|
|
eta: 0, state: 'metaDL', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads', completion_on: 0, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('downloading');
|
|
});
|
|
|
|
it('maps checkingResumeData to checking status', async () => {
|
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
|
(service as any).cookie = 'SID=resume';
|
|
clientMock.get.mockResolvedValueOnce({
|
|
data: [{
|
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0,
|
|
dlspeed: 0, upspeed: 0, downloaded: 0, uploaded: 0,
|
|
eta: 0, state: 'checkingResumeData', category: 'readmeabook', tags: '',
|
|
save_path: '/downloads', completion_on: 0, added_on: 1699000000,
|
|
}],
|
|
});
|
|
|
|
const info = await service.getDownload('abc123');
|
|
|
|
expect(info).not.toBeNull();
|
|
expect(info!.status).toBe('checking');
|
|
});
|
|
});
|
|
|
|
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('applies reverse path mapping when creating category', async () => {
|
|
const service = new QBittorrentService(
|
|
'http://qb',
|
|
'user',
|
|
'pass',
|
|
'/downloads',
|
|
'readmeabook',
|
|
false,
|
|
{ enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' }
|
|
);
|
|
(service as any).cookie = 'SID=pathmap';
|
|
clientMock.get.mockResolvedValue({ data: {} }); // No existing categories
|
|
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
|
|
|
await (service as any).ensureCategory('readmeabook');
|
|
|
|
expect(clientMock.post).toHaveBeenCalledWith(
|
|
'/torrents/createCategory',
|
|
expect.any(URLSearchParams),
|
|
expect.any(Object)
|
|
);
|
|
|
|
// Verify the savePath was reverse transformed (local → remote)
|
|
const postCall = clientMock.post.mock.calls[0];
|
|
const params = postCall[1] as URLSearchParams;
|
|
expect(params.get('savePath')).toBe('F:\\Docker\\downloads');
|
|
});
|
|
|
|
it('applies reverse path mapping when updating category', async () => {
|
|
const service = new QBittorrentService(
|
|
'http://qb',
|
|
'user',
|
|
'pass',
|
|
'/downloads',
|
|
'readmeabook',
|
|
false,
|
|
{ enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' }
|
|
);
|
|
(service as any).cookie = 'SID=pathmap-update';
|
|
// Category exists with old path
|
|
clientMock.get.mockResolvedValue({
|
|
data: {
|
|
readmeabook: { savePath: 'F:\\OldPath' },
|
|
},
|
|
});
|
|
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
|
|
|
await (service as any).ensureCategory('readmeabook');
|
|
|
|
expect(clientMock.post).toHaveBeenCalledWith(
|
|
'/torrents/editCategory',
|
|
expect.any(URLSearchParams),
|
|
expect.any(Object)
|
|
);
|
|
|
|
// Verify the savePath was reverse transformed (local → remote)
|
|
const postCall = clientMock.post.mock.calls[0];
|
|
const params = postCall[1] as URLSearchParams;
|
|
expect(params.get('savePath')).toBe('F:\\Docker\\downloads');
|
|
});
|
|
|
|
it('does not update category when remote path already matches', async () => {
|
|
const service = new QBittorrentService(
|
|
'http://qb',
|
|
'user',
|
|
'pass',
|
|
'/downloads',
|
|
'readmeabook',
|
|
false,
|
|
{ enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' }
|
|
);
|
|
(service as any).cookie = 'SID=pathmap-match';
|
|
// Category already has the correct remote path
|
|
clientMock.get.mockResolvedValue({
|
|
data: {
|
|
readmeabook: { savePath: 'F:\\Docker\\downloads' },
|
|
},
|
|
});
|
|
|
|
await (service as any).ensureCategory('readmeabook');
|
|
|
|
// Should not call post since path already matches
|
|
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('4.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 () => {
|
|
// Mock: no qBittorrent client configured
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null);
|
|
|
|
await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not configured');
|
|
});
|
|
|
|
it('returns a cached instance after successful initialization', async () => {
|
|
// Mock: qBittorrent client configured
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
url: 'http://qb',
|
|
username: 'user',
|
|
password: 'pass',
|
|
disableSSLVerify: false,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
configServiceMock.get.mockResolvedValue('/downloads');
|
|
|
|
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue({ success: true, message: 'Connected' });
|
|
|
|
const first = await getQBittorrentService();
|
|
const second = await getQBittorrentService();
|
|
|
|
expect(first).toBe(second);
|
|
// Should only call getClientForProtocol once (cached after first call)
|
|
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledTimes(1);
|
|
|
|
testConnectionSpy.mockRestore();
|
|
});
|
|
|
|
it('throws when connection test fails during service creation', async () => {
|
|
// Mock: qBittorrent client configured
|
|
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
|
id: 'client-1',
|
|
type: 'qbittorrent',
|
|
name: 'qBittorrent',
|
|
enabled: true,
|
|
url: 'http://qb',
|
|
username: 'user',
|
|
password: 'pass',
|
|
disableSSLVerify: false,
|
|
remotePathMappingEnabled: false,
|
|
});
|
|
configServiceMock.get.mockResolvedValue('/downloads');
|
|
|
|
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue({ success: false, message: 'qBittorrent connection test failed. Please check your configuration in admin settings.' });
|
|
|
|
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 result = await service.testConnection();
|
|
|
|
expect(result.success).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 result = await service.testConnection();
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(loginSpy).toHaveBeenCalled();
|
|
});
|
|
});
|