Add Transmission/NZBGet and per-client paths and much more

Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
kikootwo
2026-02-09 19:45:43 -05:00
parent d7acd67aa4
commit 4b90b35748
117 changed files with 9346 additions and 1488 deletions
+26 -9
View File
@@ -14,6 +14,9 @@ const requireAdminMock = vi.hoisted(() => vi.fn());
const configServiceMock = vi.hoisted(() => ({ get: vi.fn() }));
const qbittorrentMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
const downloadClientManagerMock = vi.hoisted(() => ({
getClientServiceForProtocol: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
@@ -36,12 +39,17 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({
getSABnzbdService: async () => sabnzbdMock,
}));
vi.mock('@/lib/services/download-client-manager.service', () => ({
getDownloadClientManager: () => downloadClientManagerMock,
}));
describe('Admin downloads route', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'admin-1', role: 'admin' } };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
downloadClientManagerMock.getClientServiceForProtocol.mockReset();
});
it('returns formatted active downloads', async () => {
@@ -53,11 +61,15 @@ describe('Admin downloads route', () => {
updatedAt: new Date(),
audiobook: { title: 'Title', author: 'Author' },
user: { plexUsername: 'user' },
downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading' }],
downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading', downloadClient: 'qbittorrent' }],
},
]);
configServiceMock.get.mockResolvedValueOnce('qbittorrent');
qbittorrentMock.getTorrent.mockResolvedValueOnce({ dlspeed: 123, eta: 60 });
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValueOnce({
getDownload: vi.fn().mockResolvedValue({
downloadSpeed: 123,
eta: 60,
}),
});
const { GET } = await import('@/app/api/admin/downloads/active/route');
const response = await GET({} as any);
@@ -76,11 +88,15 @@ describe('Admin downloads route', () => {
updatedAt: new Date(),
audiobook: { title: 'Title', author: 'Author' },
user: { plexUsername: 'user' },
downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'NZB', downloadStatus: 'downloading' }],
downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'NZB', downloadStatus: 'downloading', downloadClient: 'sabnzbd' }],
},
]);
configServiceMock.get.mockResolvedValueOnce('sabnzbd');
sabnzbdMock.getNZB.mockResolvedValueOnce({ downloadSpeed: 555, timeLeft: 120 });
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValueOnce({
getDownload: vi.fn().mockResolvedValue({
downloadSpeed: 555,
eta: 120,
}),
});
const { GET } = await import('@/app/api/admin/downloads/active/route');
const response = await GET({} as any);
@@ -99,11 +115,12 @@ describe('Admin downloads route', () => {
updatedAt: new Date(),
audiobook: { title: 'Title', author: 'Author' },
user: { plexUsername: 'user' },
downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading' }],
downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading', downloadClient: 'qbittorrent' }],
},
]);
configServiceMock.get.mockResolvedValueOnce('qbittorrent');
qbittorrentMock.getTorrent.mockRejectedValueOnce(new Error('client down'));
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValueOnce({
getDownload: vi.fn().mockRejectedValue(new Error('client down')),
});
const { GET } = await import('@/app/api/admin/downloads/active/route');
const response = await GET({} as any);
+2 -2
View File
@@ -152,8 +152,8 @@ describe('Admin settings core routes', () => {
it('rejects invalid download client types', async () => {
const request = {
json: vi.fn().mockResolvedValue({
type: 'transmission',
url: 'http://transmission',
type: 'deluge',
url: 'http://deluge',
}),
};
+30 -13
View File
@@ -35,6 +35,7 @@ const configServiceMock = vi.hoisted(() => ({
}));
const downloadClientManagerMock = vi.hoisted(() => ({
getAllClients: vi.fn(),
testConnection: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
@@ -70,6 +71,13 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({
},
}));
vi.mock('@/lib/integrations/transmission.service', () => ({
TransmissionService: class {
constructor() {}
testConnection = vi.fn();
},
}));
vi.mock('@/lib/services/ebook-scraper', () => ({
testFlareSolverrConnection: testFlareSolverrMock,
}));
@@ -189,7 +197,10 @@ describe('Admin settings test routes', () => {
});
it('tests download client connection', async () => {
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0');
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
success: true,
message: 'Successfully connected to qbittorrent (v4.0.0)',
});
const request = {
json: vi.fn().mockResolvedValue({ type: 'qbittorrent', url: 'http://qbt', username: 'user', password: 'pass' }),
};
@@ -199,7 +210,7 @@ describe('Admin settings test routes', () => {
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.version).toBe('4.0.0');
expect(payload.message).toContain('4.0.0');
});
it('validates required fields for download client testing', async () => {
@@ -229,7 +240,10 @@ describe('Admin settings test routes', () => {
downloadClientManagerMock.getAllClients.mockResolvedValueOnce([
{ type: 'qbittorrent', password: 'stored-pass' },
]);
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.1.0');
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
success: true,
message: 'Successfully connected to qbittorrent (v4.1.0)',
});
const request = {
json: vi.fn().mockResolvedValue({
type: 'qbittorrent',
@@ -244,12 +258,6 @@ describe('Admin settings test routes', () => {
const payload = await response.json();
expect(payload.success).toBe(true);
expect(qbtMock.testConnectionWithCredentials).toHaveBeenCalledWith(
'http://qbt',
'user',
'stored-pass',
false
);
});
it('returns error when masked password is missing in storage', async () => {
@@ -273,7 +281,10 @@ describe('Admin settings test routes', () => {
});
it('returns error when SABnzbd connection fails', async () => {
sabnzbdMock.testConnection.mockResolvedValueOnce({ success: false, error: 'bad key' });
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
success: false,
message: 'bad key',
});
const request = {
json: vi.fn().mockResolvedValue({ type: 'sabnzbd', url: 'http://sab', password: 'key' }),
};
@@ -282,12 +293,15 @@ describe('Admin settings test routes', () => {
const response = await POST(request as any);
const payload = await response.json();
expect(response.status).toBe(500);
expect(response.status).toBe(400);
expect(payload.error).toMatch(/bad key/);
});
it('requires path mapping values when enabled', async () => {
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0');
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
success: true,
message: 'Connected',
});
const request = {
json: vi.fn().mockResolvedValue({
type: 'qbittorrent',
@@ -307,7 +321,10 @@ describe('Admin settings test routes', () => {
});
it('rejects inaccessible local path when mapping is enabled', async () => {
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0');
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
success: true,
message: 'Connected',
});
fsMock.access.mockRejectedValueOnce(new Error('missing'));
const request = {
json: vi.fn().mockResolvedValue({
+1 -1
View File
@@ -67,7 +67,7 @@ describe('Audiobooks search torrents route', () => {
.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10 }]))
.mockResolvedValueOnce(null);
groupIndexersMock.mockReturnValue([{ categories: [1], indexerIds: [1] }]);
groupIndexersMock.mockReturnValue({ groups: [{ categories: [1], indexerIds: [1] }], skippedIndexers: [] });
prowlarrMock.search.mockResolvedValue([{ title: 'Result', size: 100, indexer: 'Indexer', indexerId: 1 }]);
rankTorrentsMock.mockReturnValue([
{
@@ -79,6 +79,10 @@ describe('Request action routes', () => {
userId: 'user-1',
audiobook: { title: 'Title', author: 'Author' },
});
prismaMock.user.findUnique.mockResolvedValueOnce({
role: 'user',
interactiveSearchAccess: null,
});
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10 }]));
configServiceMock.get.mockResolvedValueOnce(null);
prowlarrMock.search.mockResolvedValueOnce([{ title: 'Result', size: 100 }]);
@@ -468,6 +468,7 @@ describe('Request Approval Workflow', () => {
plexUsername: true,
role: true,
autoApproveRequests: true,
interactiveSearchAccess: true,
},
});
});
+26 -4
View File
@@ -13,6 +13,9 @@ const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob:
const requireAuthMock = vi.hoisted(() => vi.fn());
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
const downloadClientManagerMock = vi.hoisted(() => ({
getClientServiceForProtocol: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
@@ -30,6 +33,14 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({
getSABnzbdService: async () => sabnzbdMock,
}));
vi.mock('@/lib/services/download-client-manager.service', () => ({
getDownloadClientManager: () => downloadClientManagerMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => ({}),
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
@@ -43,6 +54,7 @@ describe('Request by ID API routes', () => {
json: vi.fn(),
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
downloadClientManagerMock.getClientServiceForProtocol.mockReset();
});
it('returns 403 when user is not authorized to view the request', async () => {
@@ -200,9 +212,14 @@ describe('Request by ID API routes', () => {
id: 'req-5',
userId: 'user-1',
audiobook: { id: 'ab-5' },
downloadHistory: [{ torrentHash: 'hash-1', selected: true }],
downloadHistory: [{ torrentHash: 'hash-1', selected: true, downloadClient: 'qbittorrent' }],
});
qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' });
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue({
clientType: 'qbittorrent',
getDownload: vi.fn().mockResolvedValue({
downloadPath: '/downloads/Book',
}),
});
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-5',
status: 'processing',
@@ -230,9 +247,14 @@ describe('Request by ID API routes', () => {
id: 'req-6',
userId: 'user-1',
audiobook: { id: 'ab-6' },
downloadHistory: [{ nzbId: 'nzb-1', selected: true }],
downloadHistory: [{ nzbId: 'nzb-1', selected: true, downloadClient: 'sabnzbd' }],
});
sabnzbdMock.getNZB.mockResolvedValue({ downloadPath: '/usenet/book' });
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue({
clientType: 'sabnzbd',
getDownload: vi.fn().mockResolvedValue({
downloadPath: '/usenet/book',
}),
});
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-6',
status: 'processing',
+183
View File
@@ -0,0 +1,183 @@
/**
* Component: Setup Route Guard Tests
* Documentation: documentation/testing.md
*
* Verifies that all setup API endpoints return 403 after setup is complete.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const prismaMock = vi.hoisted(() => ({
configuration: {
findUnique: vi.fn(),
},
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
// Mock all external dependencies that setup routes import
vi.mock('@/lib/integrations/plex.service', () => ({
getPlexService: () => ({
testConnection: vi.fn(),
getLibraries: vi.fn(),
}),
}));
vi.mock('@/lib/integrations/prowlarr.service', () => ({
ProwlarrService: class {
constructor() {}
getIndexers = vi.fn();
},
}));
vi.mock('openid-client', () => ({
Issuer: { discover: vi.fn() },
}));
vi.mock('fs/promises', () => ({
default: {
access: vi.fn(),
mkdir: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
},
access: vi.fn(),
mkdir: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
constants: { R_OK: 4 },
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => ({ get: vi.fn() }),
}));
vi.mock('@/lib/services/download-client-manager.service', () => ({
getDownloadClientManager: () => ({ testConnection: vi.fn() }),
}));
vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => ({ encrypt: vi.fn((v: string) => `enc-${v}`) }),
}));
vi.mock('bcrypt', () => ({
default: { hash: vi.fn() },
hash: vi.fn(),
}));
vi.mock('@/lib/utils/jwt', () => ({
generateAccessToken: vi.fn(() => 'token'),
generateRefreshToken: vi.fn(() => 'token'),
}));
function mockSetupComplete() {
prismaMock.configuration.findUnique.mockResolvedValue({ key: 'setup_completed', value: 'true' });
}
function makeRequest(body: Record<string, unknown> = {}) {
return {
json: vi.fn().mockResolvedValue(body),
nextUrl: { pathname: '/api/setup/test' },
} as any;
}
describe('Setup route guard - blocks access after setup is complete', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSetupComplete();
});
it('POST /api/setup/complete returns 403 when setup is already complete', async () => {
const { POST } = await import('@/app/api/setup/complete/route');
const response = await POST(makeRequest({ backendMode: 'plex' }));
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
expect(payload.message).toMatch(/Setup has already been completed/);
});
it('POST /api/setup/test-download-client returns 403 when setup is already complete', async () => {
const { POST } = await import('@/app/api/setup/test-download-client/route');
const response = await POST(makeRequest({ type: 'qbittorrent', url: 'http://qbt' }));
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('POST /api/setup/test-plex returns 403 when setup is already complete', async () => {
const { POST } = await import('@/app/api/setup/test-plex/route');
const response = await POST(makeRequest({ url: 'http://plex', token: 'token' }));
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('POST /api/setup/test-prowlarr returns 403 when setup is already complete', async () => {
const { POST } = await import('@/app/api/setup/test-prowlarr/route');
const response = await POST(makeRequest({ url: 'http://prowlarr', apiKey: 'key' }));
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('POST /api/setup/test-paths returns 403 when setup is already complete', async () => {
const { POST } = await import('@/app/api/setup/test-paths/route');
const response = await POST(makeRequest({ downloadDir: '/downloads', mediaDir: '/media' }));
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('POST /api/setup/test-abs returns 403 when setup is already complete', async () => {
const { POST } = await import('@/app/api/setup/test-abs/route');
const response = await POST(makeRequest({ serverUrl: 'http://abs', apiToken: 'token' }));
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('POST /api/setup/test-oidc returns 403 when setup is already complete', async () => {
const { POST } = await import('@/app/api/setup/test-oidc/route');
const response = await POST(makeRequest({
issuerUrl: 'http://issuer',
clientId: 'client',
clientSecret: 'secret',
}));
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('allows requests through when setup is not yet complete', async () => {
// Override: setup not complete
prismaMock.configuration.findUnique.mockResolvedValue(null);
const { POST } = await import('@/app/api/setup/test-download-client/route');
const response = await POST(makeRequest({ type: 'qbittorrent', url: 'http://qbt' }));
const payload = await response.json();
// Should reach the handler (not 403), even if the actual test fails
expect(response.status).not.toBe(403);
});
it('allows requests through when database is not ready', async () => {
// Override: database error
prismaMock.configuration.findUnique.mockRejectedValue(new Error('DB not ready'));
const { POST } = await import('@/app/api/setup/test-download-client/route');
const response = await POST(makeRequest({ type: 'qbittorrent', url: 'http://qbt' }));
const payload = await response.json();
// Should reach the handler (not 403) — DB not ready means setup hasn't happened
expect(response.status).not.toBe(403);
});
});
+66 -7
View File
@@ -5,6 +5,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const setupGuardPrismaMock = vi.hoisted(() => ({
configuration: {
findUnique: vi.fn(), // returns undefined by default = setup not complete
},
}));
const plexServiceMock = vi.hoisted(() => ({
testConnection: vi.fn(),
getLibraries: vi.fn(),
@@ -30,6 +36,9 @@ const fsMock = vi.hoisted(() => ({
const configServiceMock = vi.hoisted(() => ({
get: vi.fn(),
}));
const downloadClientManagerMock = vi.hoisted(() => ({
testConnection: vi.fn(),
}));
vi.mock('@/lib/integrations/plex.service', () => ({
getPlexService: () => plexServiceMock,
@@ -48,6 +57,13 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({
},
}));
vi.mock('@/lib/integrations/transmission.service', () => ({
TransmissionService: class {
constructor() {}
testConnection = vi.fn();
},
}));
vi.mock('@/lib/integrations/prowlarr.service', () => ({
ProwlarrService: class {
constructor() {}
@@ -59,12 +75,20 @@ vi.mock('openid-client', () => ({
Issuer: issuerMock,
}));
vi.mock('@/lib/db', () => ({
prisma: setupGuardPrismaMock,
}));
vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } }));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
vi.mock('@/lib/services/download-client-manager.service', () => ({
getDownloadClientManager: () => downloadClientManagerMock,
}));
describe('Setup test routes', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -135,7 +159,10 @@ describe('Setup test routes', () => {
});
it('tests qBittorrent credentials', async () => {
qbtMock.testConnectionWithCredentials.mockResolvedValue('4.0.0');
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
success: true,
message: 'Successfully connected to qBittorrent (v4.0.0)',
});
const { POST } = await import('@/app/api/setup/test-download-client/route');
const response = await POST({
@@ -149,13 +176,13 @@ describe('Setup test routes', () => {
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.version).toBe('4.0.0');
expect(payload.message).toContain('4.0.0');
});
it('rejects invalid download client type', async () => {
const { POST } = await import('@/app/api/setup/test-download-client/route');
const response = await POST({
json: vi.fn().mockResolvedValue({ type: 'transmission', url: 'http://transmission' }),
json: vi.fn().mockResolvedValue({ type: 'deluge', url: 'http://deluge' }),
} as any);
const payload = await response.json();
@@ -163,7 +190,33 @@ describe('Setup test routes', () => {
expect(payload.error).toMatch(/Invalid client type/);
});
it('tests Transmission credentials', async () => {
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
success: true,
message: 'Successfully connected to Transmission (v4.0.5)',
});
const { POST } = await import('@/app/api/setup/test-download-client/route');
const response = await POST({
json: vi.fn().mockResolvedValue({
type: 'transmission',
url: 'http://transmission:9091',
username: 'user',
password: 'pass',
}),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.message).toContain('Transmission');
});
it('rejects missing SABnzbd API key', async () => {
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
success: false,
message: 'API key is required for SABnzbd',
});
const { POST } = await import('@/app/api/setup/test-download-client/route');
const response = await POST({
json: vi.fn().mockResolvedValue({ type: 'sabnzbd', url: 'http://sab' }),
@@ -175,7 +228,10 @@ describe('Setup test routes', () => {
});
it('tests SABnzbd connection', async () => {
sabnzbdMock.testConnection.mockResolvedValue({ success: true, version: '3.0' });
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
success: true,
message: 'Successfully connected to SABnzbd (v3.0)',
});
const { POST } = await import('@/app/api/setup/test-download-client/route');
const response = await POST({
@@ -188,11 +244,14 @@ describe('Setup test routes', () => {
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.version).toBe('3.0');
expect(payload.message).toContain('3.0');
});
it('returns error when SABnzbd connection fails', async () => {
sabnzbdMock.testConnection.mockResolvedValue({ success: false, error: 'bad key' });
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
success: false,
message: 'bad key',
});
const { POST } = await import('@/app/api/setup/test-download-client/route');
const response = await POST({
@@ -204,7 +263,7 @@ describe('Setup test routes', () => {
} as any);
const payload = await response.json();
expect(response.status).toBe(500);
expect(response.status).toBe(400);
expect(payload.error).toMatch(/bad key/);
});
+1
View File
@@ -106,6 +106,7 @@ const settingsFixture = {
downloadDir: '',
mediaDir: '',
audiobookPathTemplate: '',
ebookPathTemplate: '',
metadataTaggingEnabled: true,
chapterMergingEnabled: false,
},
+224 -48
View File
@@ -6,7 +6,7 @@
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AdminUsersPage from '@/app/admin/users/page';
@@ -38,6 +38,60 @@ vi.mock('@/components/ui/Toast', () => ({
useToast: () => toastMock,
}));
const makeUser = (overrides: Record<string, any> = {}) => ({
id: 'u1',
plexUsername: 'TestUser',
plexId: 'plex-1',
role: 'user',
isSetupAdmin: false,
authProvider: 'local',
plexEmail: 'test@example.com',
avatarUrl: null,
createdAt: '',
updatedAt: '',
lastLoginAt: null,
autoApproveRequests: false,
interactiveSearchAccess: null,
_count: { requests: 0 },
...overrides,
});
/** Sets up all required SWR state for the page, with optional overrides. */
function setupSWR(opts: {
users?: any[];
pendingUsers?: any[];
autoApprove?: boolean;
interactiveSearch?: boolean;
mutateUsers?: ReturnType<typeof vi.fn>;
mutatePending?: ReturnType<typeof vi.fn>;
mutateAutoApprove?: ReturnType<typeof vi.fn>;
mutateInteractiveSearch?: ReturnType<typeof vi.fn>;
} = {}) {
const mutateUsers = opts.mutateUsers ?? vi.fn();
const mutatePending = opts.mutatePending ?? vi.fn();
const mutateAutoApprove = opts.mutateAutoApprove ?? vi.fn();
const mutateInteractiveSearch = opts.mutateInteractiveSearch ?? vi.fn();
swrState.set('/api/admin/users', {
data: { users: opts.users ?? [makeUser()] },
mutate: mutateUsers,
});
swrState.set('/api/admin/users/pending', {
data: { users: opts.pendingUsers ?? [] },
mutate: mutatePending,
});
swrState.set('/api/admin/settings/auto-approve', {
data: { autoApproveRequests: opts.autoApprove ?? false },
mutate: mutateAutoApprove,
});
swrState.set('/api/admin/settings/interactive-search', {
data: { interactiveSearchAccess: opts.interactiveSearch ?? true },
mutate: mutateInteractiveSearch,
});
return { mutateUsers, mutatePending, mutateAutoApprove, mutateInteractiveSearch };
}
describe('AdminUsersPage', () => {
beforeEach(() => {
swrState.clear();
@@ -46,22 +100,17 @@ describe('AdminUsersPage', () => {
toastMock.error.mockReset();
});
it('toggles global auto-approve and persists setting', async () => {
const mutateUsers = vi.fn();
const mutatePending = vi.fn();
const mutateGlobal = vi.fn();
swrState.set('/api/admin/users', {
data: { users: [{ id: 'u1', plexUsername: 'User', plexId: 'plex-1', role: 'user', isSetupAdmin: false, authProvider: 'local', plexEmail: null, avatarUrl: null, createdAt: '', updatedAt: '', lastLoginAt: null, autoApproveRequests: false, _count: { requests: 0 } }] },
mutate: mutateUsers,
});
swrState.set('/api/admin/users/pending', { data: { users: [] }, mutate: mutatePending });
swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: false }, mutate: mutateGlobal });
it('opens global settings modal and toggles auto-approve', async () => {
const { mutateAutoApprove, mutateUsers } = setupSWR({ autoApprove: false });
fetchJSONMock.mockResolvedValueOnce({ success: true });
render(<AdminUsersPage />);
// Open the Global Settings modal
fireEvent.click(await screen.findByRole('button', { name: /Global.*Permissions/i }));
// Click the toggle label inside the modal
fireEvent.click(await screen.findByText('Auto-Approve All Requests'));
await waitFor(() => {
@@ -69,38 +118,171 @@ describe('AdminUsersPage', () => {
method: 'PATCH',
body: JSON.stringify({ autoApproveRequests: true }),
});
expect(mutateGlobal).toHaveBeenCalled();
expect(mutateAutoApprove).toHaveBeenCalled();
expect(mutateUsers).toHaveBeenCalled();
});
});
it('edits a user role and saves changes', async () => {
const mutateUsers = vi.fn();
it('opens global settings modal and toggles interactive search', async () => {
const { mutateInteractiveSearch, mutateUsers } = setupSWR({ interactiveSearch: true });
swrState.set('/api/admin/users', {
data: {
users: [
{
id: 'u2',
plexUsername: 'LocalUser',
plexId: 'local-1',
role: 'user',
isSetupAdmin: false,
authProvider: 'local',
plexEmail: 'local@example.com',
avatarUrl: null,
createdAt: '',
updatedAt: '',
lastLoginAt: null,
autoApproveRequests: false,
_count: { requests: 2 },
},
],
},
mutate: mutateUsers,
fetchJSONMock.mockResolvedValueOnce({ success: true });
render(<AdminUsersPage />);
// Open the Global Settings modal
fireEvent.click(await screen.findByRole('button', { name: /Global.*Permissions/i }));
// Click the interactive search toggle label inside the modal
fireEvent.click(await screen.findByText('Interactive Search Access'));
await waitFor(() => {
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/settings/interactive-search', {
method: 'PATCH',
body: JSON.stringify({ interactiveSearchAccess: false }),
});
expect(mutateInteractiveSearch).toHaveBeenCalled();
expect(mutateUsers).toHaveBeenCalled();
});
});
it('shows correct permission badges in the users table', async () => {
setupSWR({
users: [
makeUser({ id: 'u-admin', plexUsername: 'AdminUser', role: 'admin' }),
makeUser({ id: 'u-manual', plexUsername: 'ManualUser', role: 'user', autoApproveRequests: false }),
makeUser({ id: 'u-approved', plexUsername: 'ApprovedUser', role: 'user', autoApproveRequests: true }),
],
autoApprove: false,
});
render(<AdminUsersPage />);
expect(await screen.findByText('Full Access')).toBeDefined();
expect(screen.getByText('Manual')).toBeDefined();
expect(screen.getByText('Auto-Approve')).toBeDefined();
});
it('shows Global Default badge when global auto-approve is on', async () => {
setupSWR({
users: [makeUser({ id: 'u-user', plexUsername: 'RegularUser', role: 'user', autoApproveRequests: false })],
autoApprove: true,
});
render(<AdminUsersPage />);
expect(await screen.findByText('Global Default')).toBeDefined();
});
it('opens user permissions modal and shows admin lock state for both permissions', async () => {
setupSWR({
users: [makeUser({ id: 'u-admin', plexUsername: 'AdminUser', role: 'admin', plexEmail: 'admin@test.com' })],
autoApprove: false,
interactiveSearch: false,
});
render(<AdminUsersPage />);
// Click the permissions badge to open modal
fireEvent.click(await screen.findByText('Full Access'));
// Modal should show user info and the locked state for both permissions
expect(await screen.findByText('User Permissions')).toBeDefined();
expect(screen.getAllByText('AdminUser').length).toBeGreaterThanOrEqual(2); // table + modal
expect(screen.getByText('Admin requests are always auto-approved')).toBeDefined();
expect(screen.getByText('Admins always have interactive search access')).toBeDefined();
});
it('opens user permissions modal and toggles auto-approve for regular user', async () => {
const { mutateUsers } = setupSWR({
users: [makeUser({ id: 'u-reg', plexUsername: 'RegularUser', autoApproveRequests: false })],
autoApprove: false,
interactiveSearch: false,
});
fetchJSONMock.mockResolvedValueOnce({ success: true });
render(<AdminUsersPage />);
// Click the Manual badge to open permissions modal
fireEvent.click(await screen.findByText('Manual'));
// Find and click the auto-approve toggle switch inside the modal
const toggle = await screen.findByRole('switch', { name: 'Auto-Approve Requests' });
fireEvent.click(toggle);
await waitFor(() => {
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/u-reg', {
method: 'PUT',
body: JSON.stringify({ role: 'user', autoApproveRequests: true }),
});
});
});
it('opens user permissions modal and toggles interactive search for regular user', async () => {
setupSWR({
users: [makeUser({ id: 'u-reg', plexUsername: 'RegularUser', autoApproveRequests: false, interactiveSearchAccess: false })],
autoApprove: false,
interactiveSearch: false,
});
fetchJSONMock.mockResolvedValueOnce({ success: true });
render(<AdminUsersPage />);
// Click the Manual badge to open permissions modal
fireEvent.click(await screen.findByText('Manual'));
// Find and click the interactive search toggle switch inside the modal
const toggle = await screen.findByRole('switch', { name: 'Interactive Search Access' });
fireEvent.click(toggle);
await waitFor(() => {
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/u-reg', {
method: 'PUT',
body: JSON.stringify({ role: 'user', interactiveSearchAccess: true }),
});
});
});
it('shows global override message in permissions modal when global is on', async () => {
setupSWR({
users: [makeUser({ id: 'u-reg', plexUsername: 'RegularUser', autoApproveRequests: false })],
autoApprove: true,
interactiveSearch: true,
});
render(<AdminUsersPage />);
// Click the Global Default badge
fireEvent.click(await screen.findByText('Global Default'));
// Modal should show the global override message for both
expect(await screen.findByText('Controlled by global auto-approve setting')).toBeDefined();
expect(screen.getByText('Controlled by global interactive search setting')).toBeDefined();
// Both toggles should be disabled
const autoApproveToggle = screen.getByRole('switch', { name: 'Auto-Approve Requests' });
expect(autoApproveToggle).toHaveProperty('disabled', true);
const searchToggle = screen.getByRole('switch', { name: 'Interactive Search Access' });
expect(searchToggle).toHaveProperty('disabled', true);
});
it('edits a user role and saves changes', async () => {
const { mutateUsers } = setupSWR({
users: [
makeUser({
id: 'u2',
plexUsername: 'LocalUser',
plexId: 'local-1',
plexEmail: 'local@example.com',
autoApproveRequests: false,
_count: { requests: 2 },
}),
],
autoApprove: true,
});
swrState.set('/api/admin/users/pending', { data: { users: [] }, mutate: vi.fn() });
swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: true }, mutate: vi.fn() });
fetchJSONMock.mockResolvedValueOnce({ success: true });
@@ -120,17 +302,11 @@ describe('AdminUsersPage', () => {
});
it('approves a pending user and refreshes lists', async () => {
const mutateUsers = vi.fn();
const mutatePending = vi.fn();
swrState.set('/api/admin/users', { data: { users: [] }, mutate: mutateUsers });
swrState.set('/api/admin/users/pending', {
data: {
users: [{ id: 'p1', plexUsername: 'Pending', plexEmail: null, authProvider: 'local', createdAt: new Date().toISOString() }],
},
mutate: mutatePending,
const { mutateUsers, mutatePending } = setupSWR({
users: [],
pendingUsers: [{ id: 'p1', plexUsername: 'Pending', plexEmail: null, authProvider: 'local', createdAt: new Date().toISOString() }],
autoApprove: true,
});
swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: true }, mutate: vi.fn() });
fetchJSONMock.mockResolvedValueOnce({ success: true });
@@ -62,6 +62,7 @@ const baseSettings = {
downloadDir: '',
mediaDir: '',
audiobookPathTemplate: '',
ebookPathTemplate: '',
metadataTaggingEnabled: true,
chapterMergingEnabled: false,
},
@@ -54,6 +54,7 @@ const baseSettings = {
downloadDir: '/downloads',
mediaDir: '/media',
audiobookPathTemplate: '',
ebookPathTemplate: '',
metadataTaggingEnabled: true,
chapterMergingEnabled: false,
},
+4
View File
@@ -33,6 +33,10 @@ vi.mock('@/components/requests/RequestCard', () => ({
),
}));
vi.mock('@/contexts/PreferencesContext', () => ({
usePreferences: () => ({ squareCovers: false, setSquareCovers: vi.fn(), cardSize: 5, setCardSize: vi.fn() }),
}));
describe('RequestsPage', () => {
beforeEach(() => {
resetMockAuthState();
@@ -13,7 +13,7 @@ import { DownloadClientStep } from '@/app/setup/steps/DownloadClientStep';
interface DownloadClient {
id: string;
type: 'qbittorrent' | 'sabnzbd';
type: 'qbittorrent' | 'sabnzbd' | 'transmission';
name: string;
enabled: boolean;
url: string;
@@ -469,34 +469,39 @@ describe('DownloadClientStep', () => {
});
describe('Client Type Restrictions', () => {
it('shows "Already configured" when qBittorrent is already added', () => {
it('shows "Protocol already configured" when a torrent client is already added', () => {
const mockClient = createMockClient({ type: 'qbittorrent' });
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
// "Already configured" text should appear for qBittorrent
expect(screen.getByText('Already configured')).toBeInTheDocument();
// "Protocol already configured" text should appear for torrent clients
const configuredMessages = screen.getAllByText('Protocol already configured');
expect(configuredMessages.length).toBeGreaterThanOrEqual(1);
// Add qBittorrent button should not exist
// Add qBittorrent and Add Transmission buttons should not exist (torrent protocol taken)
expect(screen.queryByRole('button', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Add Transmission/i })).not.toBeInTheDocument();
// SABnzbd should still have Add button
// SABnzbd should still have Add button (different protocol)
expect(screen.getByRole('button', { name: /Add SABnzbd/i })).toBeInTheDocument();
});
it('shows "Already configured" when SABnzbd is already added', () => {
it('shows "Protocol already configured" when SABnzbd is already added', () => {
const mockClient = createMockClient({ type: 'sabnzbd', name: 'My SABnzbd' });
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
// "Already configured" text should appear for SABnzbd
expect(screen.getByText('Already configured')).toBeInTheDocument();
// "Protocol already configured" text should appear for both usenet client cards (SABnzbd + NZBGet)
const configuredMessages = screen.getAllByText('Protocol already configured');
expect(configuredMessages.length).toBe(2);
// Add SABnzbd button should not exist
// Add SABnzbd and NZBGet buttons should not exist (usenet protocol taken)
expect(screen.queryByRole('button', { name: /Add SABnzbd/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Add NZBGet/i })).not.toBeInTheDocument();
// qBittorrent should still have Add button
// Torrent clients should still have Add buttons
expect(screen.getByRole('button', { name: /Add qBittorrent/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Add Transmission/i })).toBeInTheDocument();
});
});
@@ -635,9 +640,9 @@ describe('DownloadClientStep', () => {
expect(editButtons).toHaveLength(2);
});
// Both "Already configured" messages should appear
const alreadyConfiguredMessages = screen.getAllByText('Already configured');
expect(alreadyConfiguredMessages).toHaveLength(2);
// Both "Protocol already configured" messages should appear (torrent + usenet)
const alreadyConfiguredMessages = screen.getAllByText('Protocol already configured');
expect(alreadyConfiguredMessages.length).toBeGreaterThanOrEqual(2);
});
});
@@ -51,7 +51,7 @@ describe('IndexerConfigModal', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it('validates that at least one category is selected', () => {
it('shows warning when all audiobook categories are deselected but still allows save', () => {
const onSave = vi.fn();
render(
@@ -72,11 +72,18 @@ describe('IndexerConfigModal', () => {
}
fireEvent.click(within(audiobookRow).getByRole('switch'));
// Warning should be shown instead of blocking save
expect(screen.getByText(/will not be searched for audiobooks/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add Indexer' }));
// Component now shows specific error for audiobook categories
expect(screen.getByText('At least one audiobook category must be selected')).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
// Save should still be called with empty audiobook categories
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
audiobookCategories: [],
})
);
});
it('forces RSS to false when the indexer does not support RSS', () => {
@@ -34,6 +34,22 @@ vi.mock('next/image', () => ({
default: (props: any) => <img {...props} />,
}));
vi.mock('@/contexts/PreferencesContext', () => ({
usePreferences: () => ({ squareCovers: false, setSquareCovers: vi.fn(), cardSize: 5, setCardSize: vi.fn() }),
}));
vi.mock('@/contexts/AuthContext', () => ({
useAuth: () => ({
user: { id: 'user-1', role: 'user', permissions: { interactiveSearch: true } },
accessToken: 'test-token',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
refreshToken: vi.fn(),
setAuthData: vi.fn(),
}),
}));
const baseRequest = {
id: 'req-1',
status: 'pending',
+5 -2
View File
@@ -30,13 +30,16 @@ describe('VersionBadge', () => {
it('renders semantic version from build-time env var', async () => {
process.env.NEXT_PUBLIC_APP_VERSION = '1.0.0';
process.env.NEXT_PUBLIC_GIT_COMMIT = 'abcdef1234';
const fetchMock = vi.fn();
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({ version: '1.0.0' }),
});
vi.stubGlobal('fetch', fetchMock);
render(<VersionBadge />);
expect(await screen.findByText('v1.0.0')).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
// Should not call /api/version since build-time version is available
expect(fetchMock).not.toHaveBeenCalledWith('/api/version');
});
it('falls back to API when build-time version is unavailable', async () => {
+1
View File
@@ -18,4 +18,5 @@ export const createJobQueueMock = () => ({
addRetryMissingTorrentsJob: vi.fn(),
addRetryFailedImportsJob: vi.fn(),
addCleanupSeededTorrentsJob: vi.fn(),
addNotificationJob: vi.fn().mockResolvedValue(undefined),
});
File diff suppressed because it is too large Load Diff
+191 -8
View File
@@ -102,12 +102,195 @@ describe('QBittorrentService', () => {
size: 1000,
dlspeed: 0,
eta: 0,
state: 'allocating' as any,
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 paused', () => {
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('paused');
});
});
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('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 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: 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('paused');
});
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,
@@ -619,7 +802,7 @@ describe('QBittorrentService', () => {
const version = await QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass');
expect(version).toBe('v4.6.0');
expect(version).toBe('4.6.0');
});
it('throws when test connection receives no cookies', async () => {
@@ -709,7 +892,7 @@ describe('QBittorrentService', () => {
});
configServiceMock.get.mockResolvedValue('/downloads');
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(true);
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue({ success: true, message: 'Connected' });
const first = await getQBittorrentService();
const second = await getQBittorrentService();
@@ -736,7 +919,7 @@ describe('QBittorrentService', () => {
});
configServiceMock.get.mockResolvedValue('/downloads');
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(false);
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');
@@ -747,9 +930,9 @@ describe('QBittorrentService', () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
const loginSpy = vi.spyOn(service, 'login').mockRejectedValue(new Error('bad auth'));
const ok = await service.testConnection();
const result = await service.testConnection();
expect(ok).toBe(false);
expect(result.success).toBe(false);
expect(loginSpy).toHaveBeenCalled();
});
@@ -757,9 +940,9 @@ describe('QBittorrentService', () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
const loginSpy = vi.spyOn(service, 'login').mockResolvedValue();
const ok = await service.testConnection();
const result = await service.testConnection();
expect(ok).toBe(true);
expect(result.success).toBe(true);
expect(loginSpy).toHaveBeenCalled();
});
});
+49 -28
View File
@@ -8,10 +8,13 @@ import { SABnzbdService, getSABnzbdService, invalidateSABnzbdService } from '@/l
const clientMock = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}));
const axiosMock = vi.hoisted(() => ({
create: vi.fn(() => clientMock),
get: vi.fn(),
isAxiosError: vi.fn(() => false),
}));
const configServiceMock = vi.hoisted(() => ({
@@ -43,6 +46,8 @@ describe('SABnzbdService', () => {
beforeEach(() => {
vi.clearAllMocks();
clientMock.get.mockReset();
clientMock.post.mockReset();
axiosMock.get.mockReset();
configServiceMock.get.mockReset();
downloadClientManagerMock.getClientForProtocol.mockReset();
downloadClientManagerMock.getAllClients.mockReset();
@@ -56,7 +61,7 @@ describe('SABnzbdService', () => {
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.error).toContain('API key is required');
expect(result.message).toContain('API key is required');
expect(clientMock.get).not.toHaveBeenCalled();
});
@@ -69,7 +74,7 @@ describe('SABnzbdService', () => {
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid API key');
expect(result.message).toContain('Invalid API key');
expect(clientMock.get).toHaveBeenCalledTimes(1);
});
@@ -82,7 +87,7 @@ describe('SABnzbdService', () => {
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.error).toBe('No permissions');
expect(result.message).toBe('No permissions');
});
it('returns version when connection succeeds', async () => {
@@ -105,7 +110,7 @@ describe('SABnzbdService', () => {
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.error).toContain('SSL/TLS certificate error');
expect(result.message).toContain('SSL/TLS certificate error');
});
it('returns a friendly error on connection refused', async () => {
@@ -115,7 +120,7 @@ describe('SABnzbdService', () => {
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.error).toContain('Connection refused');
expect(result.message).toContain('Connection refused');
});
it('adds NZB with mapped priority', async () => {
@@ -123,10 +128,16 @@ describe('SABnzbdService', () => {
clientMock.get
.mockResolvedValueOnce({
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { books: { dir: '' } } } },
})
.mockResolvedValueOnce({
data: { status: true, nzo_ids: ['nzb-1'] },
});
// Mock NZB file download (global axios.get)
axiosMock.get.mockResolvedValueOnce({
data: Buffer.from('fake-nzb-content'),
headers: {},
});
// Mock addfile upload (POST instead of GET)
clientMock.post.mockResolvedValueOnce({
data: { status: true, nzo_ids: ['nzb-1'] },
});
const service = new SABnzbdService('http://sab', 'key', 'books', '/downloads');
const nzbId = await service.addNZB('https://example.com/book.nzb', {
@@ -134,11 +145,8 @@ describe('SABnzbdService', () => {
priority: 'high',
});
// Second call is the addurl call
const params = clientMock.get.mock.calls[1][1].params;
expect(nzbId).toBe('nzb-1');
expect(params.cat).toBe('books');
expect(params.priority).toBe('1');
expect(clientMock.post).toHaveBeenCalledTimes(1);
});
it('adds NZB with force priority', async () => {
@@ -146,17 +154,22 @@ describe('SABnzbdService', () => {
clientMock.get
.mockResolvedValueOnce({
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } },
})
.mockResolvedValueOnce({
data: { status: true, nzo_ids: ['nzb-9'] },
});
// Mock NZB file download
axiosMock.get.mockResolvedValueOnce({
data: Buffer.from('fake-nzb-content'),
headers: {},
});
// Mock addfile upload
clientMock.post.mockResolvedValueOnce({
data: { status: true, nzo_ids: ['nzb-9'] },
});
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
await service.addNZB('https://example.com/book.nzb', { priority: 'force' });
const nzbId = await service.addNZB('https://example.com/book.nzb', { priority: 'force' });
// Second call is the addurl call
const params = clientMock.get.mock.calls[1][1].params;
expect(params.priority).toBe('2');
expect(nzbId).toBe('nzb-9');
expect(clientMock.post).toHaveBeenCalledTimes(1);
});
it('returns queue item info when NZB is active', async () => {
@@ -428,14 +441,18 @@ describe('SABnzbdService', () => {
expect(clientMock.get.mock.calls[0][1].params.mode).toBe('get_config');
});
it('throws when addNZB reports a failure', async () => {
// Mock getConfig for ensureCategory, then the addurl failure
// Mock getConfig for ensureCategory, then the upload failure
clientMock.get
.mockResolvedValueOnce({
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } },
})
.mockResolvedValueOnce({
data: { status: false, error: 'Bad NZB' },
});
axiosMock.get.mockResolvedValueOnce({
data: Buffer.from('fake-nzb-content'),
headers: {},
});
clientMock.post.mockResolvedValueOnce({
data: { status: false, error: 'Bad NZB' },
});
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
@@ -443,14 +460,18 @@ describe('SABnzbdService', () => {
});
it('throws when SABnzbd returns no NZB IDs', async () => {
// Mock getConfig for ensureCategory, then the addurl with empty IDs
// Mock getConfig for ensureCategory, then the upload with empty IDs
clientMock.get
.mockResolvedValueOnce({
data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } },
})
.mockResolvedValueOnce({
data: { status: true, nzo_ids: [] },
});
axiosMock.get.mockResolvedValueOnce({
data: Buffer.from('fake-nzb-content'),
headers: {},
});
clientMock.post.mockResolvedValueOnce({
data: { status: true, nzo_ids: [] },
});
const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads');
@@ -475,7 +496,7 @@ describe('SABnzbdService', () => {
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.error).toContain('timed out');
expect(result.message).toContain('timed out');
});
it('throws when version is missing from response', async () => {
@@ -0,0 +1,576 @@
/**
* Component: Transmission Integration Service Tests
* Documentation: documentation/phase3/download-clients.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TransmissionService } from '@/lib/integrations/transmission.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());
vi.mock('axios', () => ({
default: axiosMock,
...axiosMock,
}));
vi.mock('parse-torrent', () => ({
default: parseTorrentMock,
}));
describe('TransmissionService', () => {
beforeEach(() => {
vi.clearAllMocks();
clientMock.get.mockReset();
clientMock.post.mockReset();
axiosMock.get.mockReset();
axiosMock.post.mockReset();
parseTorrentMock.mockReset();
});
describe('constructor', () => {
it('sets clientType and protocol correctly', () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
expect(service.clientType).toBe('transmission');
expect(service.protocol).toBe('torrent');
});
});
describe('testConnection', () => {
it('returns success with version on valid connection', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
clientMock.post.mockResolvedValueOnce({
data: { result: 'success', arguments: { version: '4.0.5' } },
});
const result = await service.testConnection();
expect(result.success).toBe(true);
expect(result.version).toBe('4.0.5');
expect(result.message).toContain('Transmission');
});
it('returns failure when RPC returns error', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
clientMock.post.mockResolvedValueOnce({
data: { result: 'unauthorized' },
});
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('unauthorized');
});
it('returns failure on connection error', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
clientMock.post.mockRejectedValueOnce(new Error('Connection refused'));
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Connection refused');
});
it('returns SSL-specific errors', async () => {
const service = new TransmissionService('https://transmission', 'user', 'pass');
clientMock.post.mockRejectedValueOnce({
isAxiosError: true,
code: 'DEPTH_ZERO_SELF_SIGNED_CERT',
message: 'self signed',
});
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('SSL certificate verification failed');
});
it('returns ECONNREFUSED error with URL', async () => {
const service = new TransmissionService('http://transmission:9091', 'user', 'pass');
clientMock.post.mockRejectedValueOnce({
isAxiosError: true,
code: 'ECONNREFUSED',
message: 'refused',
});
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Connection refused');
expect(result.message).toContain('http://transmission:9091');
});
it('returns 401 authentication error', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
clientMock.post.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 401 },
message: 'Unauthorized',
});
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Authentication failed');
});
});
describe('CSRF handling', () => {
it('captures X-Transmission-Session-Id on 409 and retries', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
// First call returns 409 with session ID
clientMock.post
.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 409,
headers: { 'x-transmission-session-id': 'csrf-token-123' },
},
})
// Retry succeeds
.mockResolvedValueOnce({
data: { result: 'success', arguments: { version: '4.0.5' } },
});
const result = await service.testConnection();
expect(result.success).toBe(true);
expect(clientMock.post).toHaveBeenCalledTimes(2);
// Verify second call includes the session ID header
const secondCall = clientMock.post.mock.calls[1];
expect(secondCall[2].headers['X-Transmission-Session-Id']).toBe('csrf-token-123');
});
});
describe('addDownload', () => {
it('rejects empty URLs', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
await expect(service.addDownload('')).rejects.toThrow('Invalid download URL');
});
it('adds magnet links via torrent-add RPC', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
// getTorrentByHash - not found (no duplicate)
clientMock.post
.mockResolvedValueOnce({
data: { result: 'success', arguments: { torrents: [] } },
})
// torrent-add
.mockResolvedValueOnce({
data: {
result: 'success',
arguments: {
'torrent-added': { hashString: '0123456789abcdef0123456789abcdef01234567', name: 'Test' },
},
},
});
const hash = await service.addDownload(
'magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567'
);
expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
});
it('skips duplicate magnet links', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
// getTorrentByHash - found (duplicate)
clientMock.post.mockResolvedValueOnce({
data: {
result: 'success',
arguments: {
torrents: [{
hashString: '0123456789abcdef0123456789abcdef01234567',
name: 'Existing',
}],
},
},
});
const hash = await service.addDownload(
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'
);
expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
// Only 1 RPC call (torrent-get), no torrent-add
expect(clientMock.post).toHaveBeenCalledTimes(1);
});
it('throws on invalid magnet link', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
await expect(service.addDownload('magnet:?xt=urn:btih:')).rejects.toThrow('Invalid magnet link');
});
it('throws when Transmission rejects the magnet link', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
// No duplicate
clientMock.post
.mockResolvedValueOnce({
data: { result: 'success', arguments: { torrents: [] } },
})
// torrent-add fails
.mockResolvedValueOnce({
data: { result: 'duplicate torrent' },
});
await expect(
service.addDownload('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567')
).rejects.toThrow('Transmission rejected magnet link');
});
it('adds .torrent files via metainfo base64', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent-data') });
parseTorrentMock.mockResolvedValueOnce({ infoHash: 'parsed-hash', name: 'Book' });
// getTorrentByHash - not found
clientMock.post
.mockResolvedValueOnce({
data: { result: 'success', arguments: { torrents: [] } },
})
// torrent-add succeeds
.mockResolvedValueOnce({
data: {
result: 'success',
arguments: {
'torrent-added': { hashString: 'parsed-hash', name: 'Book' },
},
},
});
const hash = await service.addDownload('http://example.com/file.torrent');
expect(hash).toBe('parsed-hash');
// Verify metainfo was sent
const addCall = clientMock.post.mock.calls[1];
const body = addCall[0] === '/transmission/rpc' ? JSON.parse(JSON.stringify(addCall[1])) : null;
// The body should be the RPC call with metainfo
});
it('follows redirect to magnet link', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
axiosMock.get.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 302,
headers: { location: 'magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01' },
},
});
// getTorrentByHash - not found
clientMock.post
.mockResolvedValueOnce({
data: { result: 'success', arguments: { torrents: [] } },
})
// torrent-add
.mockResolvedValueOnce({
data: {
result: 'success',
arguments: {
'torrent-added': { hashString: 'abcdef0123456789abcdef0123456789abcdef01', name: 'Test' },
},
},
});
const hash = await service.addDownload('http://example.com/file.torrent');
expect(hash).toBe('abcdef0123456789abcdef0123456789abcdef01');
});
it('throws on invalid .torrent file', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('not-a-torrent') });
parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent'));
await expect(service.addDownload('http://example.com/file.torrent')).rejects.toThrow(
'Invalid .torrent file - failed to parse'
);
});
});
describe('getDownload', () => {
it('returns mapped DownloadInfo for found torrents', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
clientMock.post.mockResolvedValueOnce({
data: {
result: 'success',
arguments: {
torrents: [{
hashString: 'abc123',
name: 'Audiobook',
totalSize: 1000,
downloadedEver: 500,
percentDone: 0.5,
status: 4, // downloading
rateDownload: 1000,
eta: 500,
labels: ['readmeabook'],
downloadDir: '/downloads',
doneDate: 0,
errorString: '',
error: 0,
secondsSeeding: 3600,
uploadRatio: 0.1,
uploadedEver: 50,
}],
},
},
});
const info = await service.getDownload('abc123');
expect(info).not.toBeNull();
expect(info!.id).toBe('abc123');
expect(info!.name).toBe('Audiobook');
expect(info!.status).toBe('downloading');
expect(info!.progress).toBe(0.5);
expect(info!.downloadSpeed).toBe(1000);
expect(info!.category).toBe('readmeabook');
expect(info!.seedingTime).toBe(3600);
});
it('returns null when torrent not found after retries', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
// All retries return empty
clientMock.post.mockResolvedValue({
data: { result: 'success', arguments: { torrents: [] } },
});
const info = await service.getDownload('nonexistent');
expect(info).toBeNull();
});
it('maps error code to failed status', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
clientMock.post.mockResolvedValueOnce({
data: {
result: 'success',
arguments: {
torrents: [{
hashString: 'abc123',
name: 'Failed',
totalSize: 1000,
downloadedEver: 0,
percentDone: 0,
status: 0,
rateDownload: 0,
eta: -1,
labels: [],
downloadDir: '/downloads',
doneDate: 0,
errorString: 'Tracker error',
error: 2,
uploadRatio: -1,
uploadedEver: 0,
}],
},
},
});
const info = await service.getDownload('abc123');
expect(info).not.toBeNull();
expect(info!.status).toBe('failed');
expect(info!.errorMessage).toBe('Tracker error');
});
});
describe('status mapping', () => {
const makeService = () => new TransmissionService('http://transmission', '', '');
const mapStatus = (service: TransmissionService, status: number, error = 0) => {
return (service as any).mapStatus(status, error);
};
it('maps 0 (stopped) to paused', () => {
expect(mapStatus(makeService(), 0)).toBe('paused');
});
it('maps 1 (check-pending) to checking', () => {
expect(mapStatus(makeService(), 1)).toBe('checking');
});
it('maps 2 (checking) to checking', () => {
expect(mapStatus(makeService(), 2)).toBe('checking');
});
it('maps 3 (download-pending) to queued', () => {
expect(mapStatus(makeService(), 3)).toBe('queued');
});
it('maps 4 (downloading) to downloading', () => {
expect(mapStatus(makeService(), 4)).toBe('downloading');
});
it('maps 5 (seed-pending) to seeding', () => {
expect(mapStatus(makeService(), 5)).toBe('seeding');
});
it('maps 6 (seeding) to seeding', () => {
expect(mapStatus(makeService(), 6)).toBe('seeding');
});
it('maps any status with error > 0 to failed', () => {
expect(mapStatus(makeService(), 4, 1)).toBe('failed');
expect(mapStatus(makeService(), 6, 2)).toBe('failed');
});
});
describe('pauseDownload / resumeDownload / deleteDownload', () => {
it('pauses torrents via torrent-stop', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
clientMock.post
.mockResolvedValueOnce({
data: {
result: 'success',
arguments: {
torrents: [{ hashString: 'hash-1', name: 'Test' }],
},
},
})
.mockResolvedValueOnce({ data: { result: 'success' } });
await service.pauseDownload('hash-1');
const stopCall = clientMock.post.mock.calls[1];
expect(stopCall[1]).toEqual(
expect.objectContaining({ method: 'torrent-stop' })
);
});
it('resumes torrents via torrent-start', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
clientMock.post
.mockResolvedValueOnce({
data: {
result: 'success',
arguments: {
torrents: [{ hashString: 'hash-1', name: 'Test' }],
},
},
})
.mockResolvedValueOnce({ data: { result: 'success' } });
await service.resumeDownload('hash-1');
const startCall = clientMock.post.mock.calls[1];
expect(startCall[1]).toEqual(
expect.objectContaining({ method: 'torrent-start' })
);
});
it('deletes torrents via torrent-remove', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
clientMock.post
.mockResolvedValueOnce({
data: {
result: 'success',
arguments: {
torrents: [{ hashString: 'hash-1', name: 'Test' }],
},
},
})
.mockResolvedValueOnce({ data: { result: 'success' } });
await service.deleteDownload('hash-1', true);
const removeCall = clientMock.post.mock.calls[1];
expect(removeCall[1]).toEqual(
expect.objectContaining({
method: 'torrent-remove',
arguments: expect.objectContaining({ 'delete-local-data': true }),
})
);
});
it('throws when pause fails', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
clientMock.post.mockRejectedValueOnce(new Error('fail'));
await expect(service.pauseDownload('hash-1')).rejects.toThrow('Failed to pause torrent');
});
it('throws when resume fails', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
clientMock.post.mockRejectedValueOnce(new Error('fail'));
await expect(service.resumeDownload('hash-1')).rejects.toThrow('Failed to resume torrent');
});
it('throws when delete fails', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
clientMock.post.mockRejectedValueOnce(new Error('fail'));
await expect(service.deleteDownload('hash-1')).rejects.toThrow('Failed to delete torrent');
});
});
describe('postProcess', () => {
it('is a no-op', async () => {
const service = new TransmissionService('http://transmission', 'user', 'pass');
await expect(service.postProcess('hash-1')).resolves.toBeUndefined();
});
});
describe('path mapping', () => {
it('applies reverse path mapping for torrent-add download-dir', async () => {
const service = new TransmissionService(
'http://transmission',
'user',
'pass',
'/downloads',
'readmeabook',
false,
{ enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' }
);
// No duplicate
clientMock.post
.mockResolvedValueOnce({
data: { result: 'success', arguments: { torrents: [] } },
})
// torrent-add
.mockResolvedValueOnce({
data: {
result: 'success',
arguments: {
'torrent-added': { hashString: '0123456789abcdef0123456789abcdef01234567', name: 'Test' },
},
},
});
await service.addDownload('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567');
// Verify the torrent-add call has the remote path
const addCall = clientMock.post.mock.calls[1];
const rpcBody = addCall[1];
expect(rpcBody.arguments['download-dir']).toBe('F:\\Docker\\downloads');
});
});
});
@@ -8,9 +8,9 @@ import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
const configMock = vi.hoisted(() => ({ get: vi.fn() }));
const qbtMock = vi.hoisted(() => ({
getTorrent: vi.fn(),
deleteTorrent: vi.fn(),
const downloadClientManagerMock = vi.hoisted(() => ({
getClientServiceForProtocol: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
@@ -21,8 +21,8 @@ vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configMock,
}));
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
getQBittorrentService: async () => qbtMock,
vi.mock('@/lib/services/download-client-manager.service', () => ({
getDownloadClientManager: () => downloadClientManagerMock,
}));
describe('processCleanupSeededTorrents', () => {
@@ -66,13 +66,31 @@ describe('processCleanupSeededTorrents', () => {
expect(result.success).toBe(true);
expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-1' } });
expect(qbtMock.getTorrent).not.toHaveBeenCalled();
expect(downloadClientManagerMock.getClientServiceForProtocol).not.toHaveBeenCalled();
});
it('deletes torrents when seeding requirements are met with no shared downloads', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 30 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-1',
name: 'Torrent',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60 * 40,
}),
deleteDownload: vi.fn().mockResolvedValue(undefined),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
@@ -84,29 +102,43 @@ describe('processCleanupSeededTorrents', () => {
downloadStatus: 'completed',
indexerName: 'IndexerA',
torrentHash: 'hash-1',
downloadClientId: 'hash-1',
downloadClient: 'qbittorrent',
},
],
},
])
.mockResolvedValueOnce([]);
qbtMock.getTorrent.mockResolvedValue({
name: 'Torrent',
seeding_time: 60 * 40,
});
qbtMock.deleteTorrent.mockResolvedValue({});
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
const result = await processCleanupSeededTorrents({ jobId: 'job-3' });
expect(result.cleaned).toBe(1);
expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true);
expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-1', true);
});
it('keeps shared torrents and deletes soft-deleted request', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 10 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-2',
name: 'Torrent',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60 * 20,
}),
deleteDownload: vi.fn(),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
@@ -118,16 +150,14 @@ describe('processCleanupSeededTorrents', () => {
downloadStatus: 'completed',
indexerName: 'IndexerA',
torrentHash: 'hash-2',
downloadClientId: 'hash-2',
downloadClient: 'qbittorrent',
},
],
},
])
.mockResolvedValueOnce([{ id: 'req-4', status: 'downloaded' }]);
qbtMock.getTorrent.mockResolvedValue({
name: 'Torrent',
seeding_time: 60 * 20,
});
prismaMock.request.delete.mockResolvedValue({});
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
@@ -135,7 +165,106 @@ describe('processCleanupSeededTorrents', () => {
expect(result.skipped).toBe(1);
expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-3' } });
expect(qbtMock.deleteTorrent).not.toHaveBeenCalled();
expect(qbtClientMock.deleteDownload).not.toHaveBeenCalled();
});
it('cleans up ebook torrents downloaded via indexer', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'EbookIndexer', seedingTimeMinutes: 15 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-ebook-1',
name: 'Equal Rites - Terry Pratchett (epub)',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60 * 20, // 20 minutes, exceeds 15 min requirement
}),
deleteDownload: vi.fn().mockResolvedValue(undefined),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
id: 'req-ebook-1',
type: 'ebook',
deletedAt: null,
downloadHistory: [
{
selected: true,
downloadStatus: 'completed',
indexerName: 'EbookIndexer',
torrentHash: 'hash-ebook-1',
downloadClientId: 'hash-ebook-1',
downloadClient: 'qbittorrent',
},
],
},
])
.mockResolvedValueOnce([]); // No shared downloads
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
const result = await processCleanupSeededTorrents({ jobId: 'job-ebook-1' });
expect(result.cleaned).toBe(1);
expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-ebook-1', true);
});
it('detects shared torrents across audiobook and ebook requests', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'SharedIndexer', seedingTimeMinutes: 10 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-shared',
name: 'Shared Torrent',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60 * 30,
}),
deleteDownload: vi.fn(),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
id: 'req-audio-shared',
type: 'audiobook',
deletedAt: null,
downloadHistory: [
{
selected: true,
downloadStatus: 'completed',
indexerName: 'SharedIndexer',
torrentHash: 'hash-shared',
downloadClientId: 'hash-shared',
downloadClient: 'qbittorrent',
},
],
},
])
// Shared torrent check finds an ebook request using same hash
.mockResolvedValueOnce([{ id: 'req-ebook-shared', status: 'downloading' }]);
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
const result = await processCleanupSeededTorrents({ jobId: 'job-shared' });
expect(result.skipped).toBe(1);
expect(qbtClientMock.deleteDownload).not.toHaveBeenCalled();
});
});
@@ -15,6 +15,7 @@ const sabMock = vi.hoisted(() => ({ addNZB: vi.fn() }));
const downloadClientManagerMock = vi.hoisted(() => ({
getClientForProtocol: vi.fn(),
getClientServiceForProtocol: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
@@ -92,12 +93,18 @@ describe('processDownloadTorrent', () => {
};
it('routes torrent downloads to qBittorrent', async () => {
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
addDownload: vi.fn().mockResolvedValue('hash-1'),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
enabled: true,
category: 'readmeabook',
});
qbtMock.addTorrent.mockResolvedValue('hash-1');
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
@@ -105,8 +112,8 @@ describe('processDownloadTorrent', () => {
const result = await processDownloadTorrent(torrentPayload);
expect(result.success).toBe(true);
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
expect(qbtMock.addTorrent).toHaveBeenCalled();
expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('torrent');
expect(qbtClientMock.addDownload).toHaveBeenCalled();
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith(
'req-1',
'dh-1',
@@ -117,12 +124,18 @@ describe('processDownloadTorrent', () => {
});
it('routes NZB downloads to SABnzbd', async () => {
const sabClientMock = {
clientType: 'sabnzbd',
protocol: 'usenet',
addDownload: vi.fn().mockResolvedValue('nzb-1'),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-2',
type: 'sabnzbd',
enabled: true,
category: 'readmeabook',
});
sabMock.addNZB.mockResolvedValue('nzb-1');
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
@@ -130,8 +143,8 @@ describe('processDownloadTorrent', () => {
const result = await processDownloadTorrent(nzbPayload);
expect(result.success).toBe(true);
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('usenet');
expect(sabMock.addNZB).toHaveBeenCalled();
expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('usenet');
expect(sabClientMock.addDownload).toHaveBeenCalled();
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith(
'req-2',
'dh-2',
@@ -142,44 +155,57 @@ describe('processDownloadTorrent', () => {
});
it('throws error when no client configured for protocol', async () => {
downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null);
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(null);
prismaMock.request.update.mockResolvedValue({});
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
await expect(processDownloadTorrent(torrentPayload)).rejects.toThrow(
'No Torrent (qBittorrent) client configured'
'No torrent download client configured'
);
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('torrent');
});
it('detects protocol from result and routes appropriately', async () => {
// Torrent result
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
addDownload: vi.fn().mockResolvedValue('hash-1'),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValueOnce(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValueOnce({
id: 'client-1',
type: 'qbittorrent',
enabled: true,
category: 'readmeabook',
});
qbtMock.addTorrent.mockResolvedValue('hash-1');
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
await processDownloadTorrent(torrentPayload);
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('torrent');
// NZB result
const sabClientMock = {
clientType: 'sabnzbd',
protocol: 'usenet',
addDownload: vi.fn().mockResolvedValue('nzb-1'),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValueOnce(sabClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValueOnce({
id: 'client-2',
type: 'sabnzbd',
enabled: true,
category: 'readmeabook',
});
sabMock.addNZB.mockResolvedValue('nzb-1');
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
await processDownloadTorrent(nzbPayload);
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('usenet');
expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('usenet');
});
});
@@ -21,6 +21,7 @@ const configMock = vi.hoisted(() => ({
}));
const downloadClientManagerMock = vi.hoisted(() => ({
getClientForProtocol: vi.fn(),
getClientServiceForProtocol: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
@@ -50,20 +51,27 @@ vi.mock('@/lib/services/download-client-manager.service', () => ({
describe('processMonitorDownload', () => {
beforeEach(() => {
vi.clearAllMocks();
jobQueueMock.addNotificationJob.mockResolvedValue(undefined);
});
it('queues organize job when qBittorrent download completes', async () => {
qbtMock.getTorrent.mockResolvedValue({
content_path: '/remote/done/Book',
save_path: '/remote/done',
name: 'Book',
});
qbtMock.getDownloadProgress.mockReturnValue({
percent: 100,
state: 'completed',
speed: 0,
eta: 0,
});
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-1',
name: 'Book',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'completed',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
downloadPath: '/remote/done/Book',
}),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
@@ -96,19 +104,34 @@ describe('processMonitorDownload', () => {
'a1',
expect.stringMatching(/downloads[\\/]+Book/)
);
// Verify downloadPath is stored in download history on completion
expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
downloadStatus: 'completed',
downloadPath: expect.stringMatching(/downloads[\\/]+Book/),
}),
})
);
});
it('re-schedules monitoring when download is still active', async () => {
qbtMock.getTorrent.mockResolvedValue({
save_path: '/downloads',
name: 'Book',
});
qbtMock.getDownloadProgress.mockReturnValue({
percent: 45,
state: 'downloading',
speed: 100,
eta: 60,
});
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-2',
name: 'Book',
size: 0,
bytesDownloaded: 0,
progress: 0.45,
status: 'downloading',
downloadSpeed: 100,
eta: 60,
category: 'readmeabook',
}),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.update.mockResolvedValue({});
@@ -132,18 +155,29 @@ describe('processMonitorDownload', () => {
});
it('marks request failed when download fails', async () => {
qbtMock.getTorrent.mockResolvedValue({
save_path: '/downloads',
name: 'Book',
});
qbtMock.getDownloadProgress.mockReturnValue({
percent: 20,
state: 'failed',
speed: 0,
eta: 0,
});
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-3',
name: 'Book',
size: 0,
bytesDownloaded: 0,
progress: 0.20,
status: 'failed',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
}),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.update.mockResolvedValue({});
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-3',
audiobook: { title: 'Book', author: 'Author' },
user: { plexUsername: 'user' },
});
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
const result = await processMonitorDownload({
@@ -163,15 +197,23 @@ describe('processMonitorDownload', () => {
});
it('handles SABnzbd completion and queues organize job', async () => {
sabMock.getNZB.mockResolvedValue({
nzbId: 'nzb-1',
size: 100,
progress: 1,
status: 'completed',
downloadSpeed: 0,
timeLeft: 0,
downloadPath: '/usenet/complete/Book',
});
const sabClientMock = {
clientType: 'sabnzbd',
protocol: 'usenet',
getDownload: vi.fn().mockResolvedValue({
id: 'nzb-1',
name: 'Book',
size: 100,
bytesDownloaded: 100,
progress: 1.0,
status: 'completed',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
downloadPath: '/usenet/complete/Book',
}),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-2',
type: 'sabnzbd',
@@ -202,10 +244,76 @@ describe('processMonitorDownload', () => {
'a4',
'/usenet/complete/Book'
);
// Verify downloadPath is stored in download history on completion
expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
downloadStatus: 'completed',
downloadPath: '/usenet/complete/Book',
}),
})
);
});
it('handles NZBGet completion and queues organize job', async () => {
const nzbgetClientMock = {
clientType: 'nzbget',
protocol: 'usenet',
getDownload: vi.fn().mockResolvedValue({
id: '42',
name: 'Book',
size: 200,
bytesDownloaded: 200,
progress: 1.0,
status: 'completed',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
downloadPath: '/downloads/readmeabook/Book',
}),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(nzbgetClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-nzbget',
type: 'nzbget',
name: 'NZBGet',
enabled: true,
remotePathMappingEnabled: false,
});
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.update.mockResolvedValue({});
prismaMock.request.findFirst.mockResolvedValue({
id: 'req-nzbget',
audiobook: { id: 'a-nzbget' },
deletedAt: null,
});
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
const result = await processMonitorDownload({
requestId: 'req-nzbget',
downloadHistoryId: 'dh-nzbget',
downloadClientId: '42',
downloadClient: 'nzbget',
jobId: 'job-nzbget',
});
expect(result.completed).toBe(true);
// Verify it called getClientServiceForProtocol with 'usenet' (not 'torrent')
expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('usenet');
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
'req-nzbget',
'a-nzbget',
'/downloads/readmeabook/Book'
);
});
it('does not mark request failed for transient NZB not found errors', async () => {
sabMock.getNZB.mockResolvedValue(null);
const sabClientMock = {
clientType: 'sabnzbd',
protocol: 'usenet',
getDownload: vi.fn().mockResolvedValue(null),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
await expect(processMonitorDownload({
@@ -220,7 +328,13 @@ describe('processMonitorDownload', () => {
});
it('marks request failed when download client is unsupported', async () => {
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(null);
prismaMock.request.update.mockResolvedValue({});
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-6',
audiobook: { title: 'Book', author: 'Author' },
user: { plexUsername: 'user' },
});
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
await expect(processMonitorDownload({
@@ -229,7 +343,7 @@ describe('processMonitorDownload', () => {
downloadClientId: 'id-6',
downloadClient: 'deluge',
jobId: 'job-6',
})).rejects.toThrow(/not supported/i);
})).rejects.toThrow(/Unknown download client type: deluge/);
expect(prismaMock.request.update).toHaveBeenCalledWith(
expect.objectContaining({
@@ -239,17 +353,37 @@ describe('processMonitorDownload', () => {
});
it('marks request failed when SABnzbd completion lacks a download path', async () => {
sabMock.getNZB.mockResolvedValue({
nzbId: 'nzb-2',
size: 100,
progress: 1,
status: 'completed',
downloadSpeed: 0,
timeLeft: 0,
downloadPath: undefined,
const sabClientMock = {
clientType: 'sabnzbd',
protocol: 'usenet',
getDownload: vi.fn().mockResolvedValue({
id: 'nzb-2',
name: 'Book',
size: 100,
bytesDownloaded: 100,
progress: 1.0,
status: 'completed',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
downloadPath: undefined,
}),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-2',
type: 'sabnzbd',
name: 'SABnzbd',
enabled: true,
remotePathMappingEnabled: false,
});
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.update.mockResolvedValue({});
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-7',
audiobook: { title: 'Book', author: 'Author' },
user: { plexUsername: 'user' },
});
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
await expect(processMonitorDownload({
@@ -268,14 +402,22 @@ describe('processMonitorDownload', () => {
});
it('converts SABnzbd progress from 0.0-1.0 to 0-100 percentage', async () => {
sabMock.getNZB.mockResolvedValue({
nzbId: 'nzb-3',
size: 1000000000, // 1GB
progress: 0.35, // 35% in decimal format (0.0-1.0)
status: 'downloading',
downloadSpeed: 5000000, // 5MB/s
timeLeft: 130,
});
const sabClientMock = {
clientType: 'sabnzbd',
protocol: 'usenet',
getDownload: vi.fn().mockResolvedValue({
id: 'nzb-3',
name: 'Book',
size: 1000000000,
bytesDownloaded: 350000000,
progress: 0.35,
status: 'downloading',
downloadSpeed: 5000000,
eta: 130,
category: 'readmeabook',
}),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.update.mockResolvedValue({});
@@ -346,6 +346,58 @@ describe('processOrganizeFiles', () => {
);
});
it('queues retry when organizer returns EPERM copy failure', async () => {
prismaMock.request.update.mockResolvedValue({});
prismaMock.audiobook.findUnique.mockResolvedValue({
id: 'a-eperm',
title: 'Theo of Golden',
author: 'Allen Levi',
narrator: null,
coverArtUrl: null,
audibleAsin: 'B0FTT6KFKR',
});
// Organizer returns success: false with EPERM error (the fixed behavior)
organizerMock.organize.mockResolvedValue({
success: false,
targetPath: '/media/audiobooks/Fiction/Allen Levi/Theo of Golden B0FTT6KFKR',
filesMovedCount: 0,
errors: [
'Failed to copy Theo of Golden [B0FTT6KFKR].m4b: EPERM: operation not permitted, copyfile',
'No audio files were successfully copied to the target directory',
],
audioFiles: [],
});
prismaMock.request.findFirst.mockResolvedValue({
importAttempts: 0,
maxImportRetries: 3,
deletedAt: null,
});
configMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobook_path_template') return '{author}/{title} {asin}';
return null;
});
const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor');
const result = await processOrganizeFiles({
requestId: 'req-eperm',
audiobookId: 'a-eperm',
downloadPath: '/data/torrents/bookbit',
jobId: 'job-eperm',
});
// Should be identified as retryable and queued for re-import
expect(result.success).toBe(false);
expect(prismaMock.request.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'awaiting_import',
importAttempts: 1,
errorMessage: expect.stringContaining('EPERM'),
}),
})
);
});
it('generates and stores filesHash after successful organization', async () => {
prismaMock.request.update.mockResolvedValue({});
prismaMock.audiobook.findUnique.mockResolvedValue({
@@ -15,9 +15,8 @@ const configMock = vi.hoisted(() => ({
}));
const downloadClientManagerMock = vi.hoisted(() => ({
getClientForProtocol: vi.fn(),
getClientServiceForProtocol: vi.fn(),
}));
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
@@ -35,20 +34,29 @@ vi.mock('@/lib/services/download-client-manager.service', () => ({
getDownloadClientManager: () => downloadClientManagerMock,
}));
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
getQBittorrentService: () => qbtMock,
}));
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
getSABnzbdService: () => sabnzbdMock,
}));
describe('processRetryFailedImports', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('queues organize jobs using download client paths', async () => {
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-1',
name: 'Book',
downloadPath: '/downloads/Book',
progress: 1.0,
status: 'completed',
size: 0,
bytesDownloaded: 0,
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
}),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
@@ -65,11 +73,6 @@ describe('processRetryFailedImports', () => {
},
]);
qbtMock.getTorrent.mockResolvedValue({
save_path: '/downloads',
name: 'Book',
});
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-1' });
@@ -109,6 +112,12 @@ describe('processRetryFailedImports', () => {
});
it('falls back to configured download dir when qBittorrent lookup fails', async () => {
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockRejectedValue(new Error('not found')),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
@@ -128,8 +137,6 @@ describe('processRetryFailedImports', () => {
},
]);
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-3' });
@@ -142,6 +149,23 @@ describe('processRetryFailedImports', () => {
});
it('uses SABnzbd download path when available', async () => {
const sabClientMock = {
clientType: 'sabnzbd',
protocol: 'usenet',
getDownload: vi.fn().mockResolvedValue({
id: 'nzb-1',
name: 'Book',
downloadPath: '/remote/nzb/Book',
progress: 1.0,
status: 'completed',
size: 0,
bytesDownloaded: 0,
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
}),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-2',
type: 'sabnzbd',
@@ -159,8 +183,6 @@ describe('processRetryFailedImports', () => {
},
]);
sabnzbdMock.getNZB.mockResolvedValue({ downloadPath: '/remote/nzb/Book' });
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-4' });
@@ -173,6 +195,12 @@ describe('processRetryFailedImports', () => {
});
it('skips SABnzbd retries when download dir is missing', async () => {
const sabClientMock = {
clientType: 'sabnzbd',
protocol: 'usenet',
getDownload: vi.fn().mockResolvedValue(null),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-2',
type: 'sabnzbd',
@@ -189,8 +217,6 @@ describe('processRetryFailedImports', () => {
},
]);
sabnzbdMock.getNZB.mockResolvedValue(null);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-5' });
@@ -222,6 +248,23 @@ describe('processRetryFailedImports', () => {
});
it('tracks skipped requests when organize job fails', async () => {
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-7',
name: 'Book',
downloadPath: '/downloads/Book',
progress: 1.0,
status: 'completed',
size: 0,
bytesDownloaded: 0,
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
}),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
@@ -236,7 +279,6 @@ describe('processRetryFailedImports', () => {
downloadHistory: [{ torrentHash: 'hash-7', torrentName: 'Book', downloadClient: 'qbittorrent' }],
},
]);
qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' });
jobQueueMock.addOrganizeJob.mockRejectedValue(new Error('queue down'));
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
@@ -247,6 +289,12 @@ describe('processRetryFailedImports', () => {
});
it('skips qBittorrent fallbacks when torrent name is missing', async () => {
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockRejectedValue(new Error('not found')),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
@@ -261,7 +309,6 @@ describe('processRetryFailedImports', () => {
downloadHistory: [{ torrentHash: 'hash-8', downloadClient: 'qbittorrent' }],
},
]);
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-8' });
@@ -272,6 +319,12 @@ describe('processRetryFailedImports', () => {
});
it('skips qBittorrent fallbacks when download_dir is not configured', async () => {
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockRejectedValue(new Error('not found')),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
@@ -287,7 +340,6 @@ describe('processRetryFailedImports', () => {
downloadHistory: [{ torrentHash: 'hash-9', torrentName: 'Book', downloadClient: 'qbittorrent' }],
},
]);
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-9' });
@@ -297,6 +349,12 @@ describe('processRetryFailedImports', () => {
});
it('skips SABnzbd retries when the client throws', async () => {
const sabClientMock = {
clientType: 'sabnzbd',
protocol: 'usenet',
getDownload: vi.fn().mockRejectedValue(new Error('sab down')),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-2',
type: 'sabnzbd',
@@ -312,8 +370,6 @@ describe('processRetryFailedImports', () => {
},
]);
sabnzbdMock.getNZB.mockRejectedValue(new Error('sab down'));
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-10' });
@@ -321,6 +377,138 @@ describe('processRetryFailedImports', () => {
expect(result.skipped).toBe(1);
});
it('uses stored downloadPath when client throws', async () => {
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockRejectedValue(new Error('torrent removed')),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
remotePathMappingEnabled: false,
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-stored-1',
audiobook: { id: 'a-stored-1', title: 'Freefall' },
downloadHistory: [{
torrentHash: 'hash-stored-1',
torrentName: 'Freefall: Expeditionary Force Mavericks, Book 2 - Craig Alanson',
downloadClient: 'qbittorrent',
downloadPath: '/downloads/Craig Alanson - Freefall Expeditionary Force Mavericks, Book 2',
}],
},
]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-stored-1' });
expect(result.triggered).toBe(1);
// Should use stored path, NOT the torrentName-based fallback
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
'req-stored-1',
'a-stored-1',
'/downloads/Craig Alanson - Freefall Expeditionary Force Mavericks, Book 2'
);
});
it('falls back to torrentName when stored downloadPath is null', async () => {
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockRejectedValue(new Error('torrent removed')),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
remotePathMappingEnabled: false,
});
configMock.get.mockResolvedValue('/downloads');
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-stored-2',
audiobook: { id: 'a-stored-2', title: 'Book' },
downloadHistory: [{
torrentHash: 'hash-stored-2',
torrentName: 'Book',
downloadClient: 'qbittorrent',
downloadPath: null, // Old record without stored path
}],
},
]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-stored-2' });
expect(result.triggered).toBe(1);
// Should fall back to torrentName-based path
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
'req-stored-2',
'a-stored-2',
'/downloads/Book'
);
});
it('prefers live client path over stored downloadPath', async () => {
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-stored-3',
name: 'Book',
downloadPath: '/downloads/LivePath',
progress: 1.0,
status: 'completed',
size: 0,
bytesDownloaded: 0,
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
}),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
remotePathMappingEnabled: false,
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-stored-3',
audiobook: { id: 'a-stored-3', title: 'Book' },
downloadHistory: [{
torrentHash: 'hash-stored-3',
torrentName: 'Book',
downloadClient: 'qbittorrent',
downloadPath: '/downloads/StoredPath',
}],
},
]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-stored-3' });
expect(result.triggered).toBe(1);
// Should use live client path, NOT stored path
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
'req-stored-3',
'a-stored-3',
'/downloads/LivePath'
);
});
it('skips requests without download_dir when no client identifiers exist', async () => {
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
@@ -344,6 +532,226 @@ describe('processRetryFailedImports', () => {
expect(result.triggered).toBe(0);
expect(result.skipped).toBe(1);
});
// =========================================================================
// EBOOK REQUEST TESTS
// =========================================================================
it('retries ebook requests with direct download client using stored path', async () => {
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-ebook-1',
type: 'ebook',
audiobook: { id: 'a-ebook-1', title: 'Equal Rites' },
downloadHistory: [{
downloadClient: 'direct',
torrentName: 'Equal Rites - Terry Pratchett.epub',
downloadPath: '/downloads/Equal Rites - Terry Pratchett.epub',
}],
},
]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-ebook-1' });
expect(result.triggered).toBe(1);
expect(result.skipped).toBe(0);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
'req-ebook-1',
'a-ebook-1',
'/downloads/Equal Rites - Terry Pratchett.epub'
);
});
it('retries ebook requests with direct download using fallback path', async () => {
configMock.get.mockResolvedValue('/downloads');
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-ebook-2',
type: 'ebook',
audiobook: { id: 'a-ebook-2', title: 'Equal Rites' },
downloadHistory: [{
downloadClient: 'direct',
torrentName: 'Equal Rites - Terry Pratchett.epub',
downloadPath: null, // No stored path
}],
},
]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-ebook-2' });
expect(result.triggered).toBe(1);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
'req-ebook-2',
'a-ebook-2',
'/downloads/Equal Rites - Terry Pratchett.epub'
);
});
it('skips direct ebook requests when no stored path and no download_dir', async () => {
configMock.get.mockResolvedValue(null);
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-ebook-3',
type: 'ebook',
audiobook: { id: 'a-ebook-3', title: 'Equal Rites' },
downloadHistory: [{
downloadClient: 'direct',
torrentName: 'Equal Rites - Terry Pratchett.epub',
downloadPath: null,
}],
},
]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-ebook-3' });
expect(result.triggered).toBe(0);
expect(result.skipped).toBe(1);
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
});
it('retries ebook requests downloaded via indexer (torrent client)', async () => {
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-ebook-idx',
name: 'Equal Rites - Terry Pratchett (epub)',
downloadPath: '/downloads/Equal Rites - Terry Pratchett (epub)',
progress: 1.0,
status: 'completed',
size: 0,
bytesDownloaded: 0,
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
}),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
remotePathMappingEnabled: false,
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-ebook-idx',
type: 'ebook',
audiobook: { id: 'a-ebook-idx', title: 'Equal Rites' },
downloadHistory: [{
torrentHash: 'hash-ebook-idx',
torrentName: 'Equal Rites - Terry Pratchett (epub)',
downloadClient: 'qbittorrent',
}],
},
]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-ebook-idx' });
expect(result.triggered).toBe(1);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
'req-ebook-idx',
'a-ebook-idx',
'/downloads/Equal Rites - Terry Pratchett (epub)'
);
});
it('processes mixed audiobook and ebook requests in same batch', async () => {
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-audio',
name: 'Gideon the Ninth',
downloadPath: '/downloads/Gideon the Ninth',
progress: 1.0,
status: 'completed',
size: 0,
bytesDownloaded: 0,
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
}),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
remotePathMappingEnabled: false,
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-mixed-audio',
type: 'audiobook',
audiobook: { id: 'a-mixed-audio', title: 'Gideon the Ninth' },
downloadHistory: [{
torrentHash: 'hash-audio',
torrentName: 'Gideon the Ninth',
downloadClient: 'qbittorrent',
}],
},
{
id: 'req-mixed-ebook',
type: 'ebook',
audiobook: { id: 'a-mixed-ebook', title: 'Equal Rites' },
downloadHistory: [{
downloadClient: 'direct',
torrentName: 'Equal Rites - Terry Pratchett.epub',
downloadPath: '/downloads/Equal Rites - Terry Pratchett.epub',
}],
},
]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-mixed' });
expect(result.triggered).toBe(2);
expect(result.skipped).toBe(0);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledTimes(2);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
'req-mixed-audio',
'a-mixed-audio',
'/downloads/Gideon the Ninth'
);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
'req-mixed-ebook',
'a-mixed-ebook',
'/downloads/Equal Rites - Terry Pratchett.epub'
);
});
it('skips direct ebook requests with no torrentName and no stored path', async () => {
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-ebook-noname',
type: 'ebook',
audiobook: { id: 'a-ebook-noname', title: 'Book' },
downloadHistory: [{
downloadClient: 'direct',
torrentName: null,
downloadPath: null,
}],
},
]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
const result = await processRetryFailedImports({ jobId: 'job-ebook-noname' });
expect(result.triggered).toBe(0);
expect(result.skipped).toBe(1);
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
});
});
@@ -34,13 +34,19 @@ vi.mock('@/lib/services/encryption.service', () => ({
}),
}));
// Mock qBittorrent and SABnzbd services - use vi.hoisted to ensure they're available at mock time
const { qbtServiceMock, sabServiceMock } = vi.hoisted(() => ({
// Mock all 4 download client services - use vi.hoisted to ensure they're available at mock time
const { qbtServiceMock, sabServiceMock, transmissionServiceMock, nzbgetServiceMock } = vi.hoisted(() => ({
qbtServiceMock: {
testConnection: vi.fn(),
},
sabServiceMock: {
getVersion: vi.fn(),
testConnection: vi.fn(),
},
transmissionServiceMock: {
testConnection: vi.fn(),
},
nzbgetServiceMock: {
testConnection: vi.fn(),
},
}));
@@ -53,7 +59,19 @@ vi.mock('@/lib/integrations/qbittorrent.service', () => ({
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
SABnzbdService: class MockSABnzbdService {
getVersion = sabServiceMock.getVersion;
testConnection = sabServiceMock.testConnection;
},
}));
vi.mock('@/lib/integrations/transmission.service', () => ({
TransmissionService: class MockTransmissionService {
testConnection = transmissionServiceMock.testConnection;
},
}));
vi.mock('@/lib/integrations/nzbget.service', () => ({
NZBGetService: class MockNZBGetService {
testConnection = nzbgetServiceMock.testConnection;
},
}));
@@ -184,6 +202,58 @@ describe('DownloadClientManager', () => {
expect(result).toEqual(clients[0]);
});
it('returns Transmission client for torrent protocol', async () => {
const clients = [
{
id: 'client-1',
type: 'transmission',
name: 'Transmission',
enabled: true,
url: 'http://localhost:9091',
username: 'admin',
password: 'password',
disableSSLVerify: false,
remotePathMappingEnabled: false,
category: 'readmeabook',
},
];
configMock.get.mockResolvedValue(JSON.stringify(clients));
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
const manager = getDownloadClientManager(configMock as any);
const result = await manager.getClientForProtocol('torrent');
expect(result).toEqual(clients[0]);
});
it('returns NZBGet client for usenet protocol', async () => {
const clients = [
{
id: 'client-1',
type: 'nzbget',
name: 'NZBGet',
enabled: true,
url: 'http://localhost:6789',
username: 'nzbget',
password: 'tegbzn6789',
disableSSLVerify: false,
remotePathMappingEnabled: false,
category: 'readmeabook',
},
];
configMock.get.mockResolvedValue(JSON.stringify(clients));
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
const manager = getDownloadClientManager(configMock as any);
const result = await manager.getClientForProtocol('usenet');
expect(result).toEqual(clients[0]);
});
it('returns null when no client configured for protocol', async () => {
const clients = [
{
@@ -293,7 +363,7 @@ describe('DownloadClientManager', () => {
describe('testConnection', () => {
it('successfully tests qBittorrent connection', async () => {
qbtServiceMock.testConnection.mockResolvedValue(undefined);
qbtServiceMock.testConnection.mockResolvedValue({ success: true, message: 'Connected' });
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
const manager = getDownloadClientManager(configMock as any);
@@ -318,7 +388,7 @@ describe('DownloadClientManager', () => {
});
it('successfully tests SABnzbd connection', async () => {
sabServiceMock.getVersion.mockResolvedValue('3.5.0');
sabServiceMock.testConnection.mockResolvedValue({ success: true, version: '3.5.0', message: 'Connected' });
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
const manager = getDownloadClientManager(configMock as any);
@@ -365,6 +435,56 @@ describe('DownloadClientManager', () => {
expect(result.success).toBe(false);
expect(result.message).toBe('Connection refused');
});
it('successfully tests NZBGet connection', async () => {
nzbgetServiceMock.testConnection.mockResolvedValue({ success: true, version: '24.2', message: 'Connected' });
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
const manager = getDownloadClientManager(configMock as any);
const config = {
id: 'client-1',
type: 'nzbget' as const,
name: 'NZBGet',
enabled: true,
url: 'http://localhost:6789',
username: 'nzbget',
password: 'tegbzn6789',
disableSSLVerify: false,
remotePathMappingEnabled: false,
category: 'readmeabook',
};
const result = await manager.testConnection(config);
expect(result.success).toBe(true);
expect(result.message).toBe('Successfully connected to NZBGet (v24.2)');
});
it('successfully tests Transmission connection', async () => {
transmissionServiceMock.testConnection.mockResolvedValue({ success: true, version: '4.0.5', message: 'Connected' });
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
const manager = getDownloadClientManager(configMock as any);
const config = {
id: 'client-1',
type: 'transmission' as const,
name: 'Transmission',
enabled: true,
url: 'http://localhost:9091',
username: 'admin',
password: 'password',
disableSSLVerify: false,
remotePathMappingEnabled: false,
category: 'readmeabook',
};
const result = await manager.testConnection(config);
expect(result.success).toBe(true);
expect(result.message).toBe('Successfully connected to Transmission (v4.0.5)');
});
});
describe('migration', () => {
+86 -30
View File
@@ -16,12 +16,8 @@ const configServiceMock = {
get: vi.fn(),
getBackendMode: vi.fn(),
};
const qbtMock = {
getTorrent: vi.fn(),
deleteTorrent: vi.fn(),
};
const sabMock = {
deleteNZB: vi.fn(),
const downloadClientManagerMock = {
getClientServiceForProtocol: vi.fn(),
};
vi.mock('@/lib/db', () => ({
@@ -34,12 +30,8 @@ vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
getQBittorrentService: async () => qbtMock,
}));
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
getSABnzbdService: async () => sabMock,
vi.mock('@/lib/services/download-client-manager.service', () => ({
getDownloadClientManager: () => downloadClientManagerMock,
}));
vi.mock('@/lib/services/audiobookshelf/api', () => ({
@@ -59,6 +51,7 @@ describe('deleteRequest', () => {
// Default mock for child request queries (audiobook requests check for child ebook requests)
prismaMock.request.findMany.mockResolvedValue([]);
prismaMock.request.updateMany.mockResolvedValue({ count: 0 });
downloadClientManagerMock.getClientServiceForProtocol.mockReset();
});
it('returns not found when request is missing', async () => {
@@ -103,10 +96,24 @@ describe('deleteRequest', () => {
return null;
});
configServiceMock.getBackendMode.mockResolvedValue('plex');
qbtMock.getTorrent.mockResolvedValue({
name: 'Book',
seeding_time: 120,
});
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-1',
name: 'Book',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 120,
}),
deleteDownload: vi.fn().mockResolvedValue(undefined),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({
releaseDate: '2021-01-01T00:00:00.000Z',
});
@@ -122,7 +129,7 @@ describe('deleteRequest', () => {
expect(result.success).toBe(true);
expect(result.torrentsRemoved).toBe(1);
expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true);
expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-1', true);
// Code now uses deleteMany with ASIN-based matching
expect(prismaMock.plexLibrary.deleteMany).toHaveBeenCalledWith({
where: {
@@ -166,7 +173,23 @@ describe('deleteRequest', () => {
return null;
});
configServiceMock.getBackendMode.mockResolvedValue('plex');
sabMock.deleteNZB.mockResolvedValue(undefined);
const sabClientMock = {
clientType: 'sabnzbd',
protocol: 'usenet',
getDownload: vi.fn().mockResolvedValue({
id: 'nzb-1',
name: 'Book Two',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'completed',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
}),
deleteDownload: vi.fn().mockResolvedValue(undefined),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
fsMock.access.mockResolvedValue(undefined);
fsMock.rm.mockResolvedValue(undefined);
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
@@ -178,7 +201,7 @@ describe('deleteRequest', () => {
expect(result.success).toBe(true);
expect(result.torrentsRemoved).toBe(1);
expect(sabMock.deleteNZB).toHaveBeenCalledWith('nzb-1', true);
expect(sabClientMock.deleteDownload).toHaveBeenCalledWith('nzb-1', true);
expect(prismaMock.request.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ deletedBy: 'admin-1' }),
@@ -218,10 +241,24 @@ describe('deleteRequest', () => {
return null;
});
configServiceMock.getBackendMode.mockResolvedValue('plex');
qbtMock.getTorrent.mockResolvedValue({
name: 'Book Three',
seeding_time: 60,
});
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-3',
name: 'Book Three',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60,
}),
deleteDownload: vi.fn(),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({
releaseDate: '2020-01-01T00:00:00.000Z',
});
@@ -239,7 +276,6 @@ describe('deleteRequest', () => {
const result = await deleteRequest('req-3', 'admin-2');
expect(result.torrentsKeptSeeding).toBe(1);
expect(qbtMock.deleteTorrent).not.toHaveBeenCalled();
// Path doesn't exist, so rm should not be called (first access fails)
expect(fsMock.rm).not.toHaveBeenCalled();
@@ -274,10 +310,24 @@ describe('deleteRequest', () => {
return null;
});
configServiceMock.getBackendMode.mockResolvedValue('plex');
qbtMock.getTorrent.mockResolvedValue({
name: 'Book Four',
seeding_time: 0,
});
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-4',
name: 'Book Four',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 0,
}),
deleteDownload: vi.fn(),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
fsMock.access.mockRejectedValue(new Error('missing'));
prismaMock.request.update.mockResolvedValue({});
@@ -287,7 +337,6 @@ describe('deleteRequest', () => {
const result = await deleteRequest('req-4', 'admin-3');
expect(result.torrentsKeptUnlimited).toBe(1);
expect(qbtMock.deleteTorrent).not.toHaveBeenCalled();
});
it('clears audiobookshelf linkage when SABnzbd delete fails', async () => {
@@ -319,7 +368,14 @@ describe('deleteRequest', () => {
return null;
});
configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf');
sabMock.deleteNZB.mockRejectedValue(new Error('missing'));
const sabClientMock = {
clientType: 'sabnzbd',
protocol: 'usenet',
deleteDownload: vi.fn().mockRejectedValue(new Error('missing')),
getDownload: vi.fn(),
postProcess: vi.fn(),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
fsMock.access.mockRejectedValue(new Error('missing'));
prismaMock.request.update.mockResolvedValue({});
+256 -2
View File
@@ -473,7 +473,7 @@ describe('file organizer', () => {
expect(result.isFile).toBe(true);
});
it('adds errors when source audio files are missing', async () => {
it('returns failure when source audio files are missing', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
configState.values.set('ebook_sidecar_enabled', 'false');
@@ -498,8 +498,10 @@ describe('file organizer', () => {
author: 'Author',
}, '{author}/{title}');
expect(result.success).toBe(true);
expect(result.success).toBe(false);
expect(result.audioFiles).toEqual([]);
expect(result.errors.join(' ')).toContain('Source file not found');
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
expect(fsMock.copyFile).not.toHaveBeenCalled();
});
@@ -646,4 +648,256 @@ describe('file organizer', () => {
expect((organizer as any).mediaDir).toBe('/media/custom');
expect((organizer as any).tempDir).toBe('/tmp/custom');
});
it('returns failure when all audio file copies fail (EPERM)', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
const expectedDir = path.join('/media', 'Author', 'Book');
const targetFile = path.join(expectedDir, 'book.m4b');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
}, '{author}/{title}');
expect(result.success).toBe(false);
expect(result.audioFiles).toEqual([]);
expect(result.filesMovedCount).toBe(0);
expect(result.targetPath).toBe(expectedDir);
expect(result.errors.join(' ')).toContain('EPERM');
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
});
it('falls back to untagged file when tagged copy fails', async () => {
configState.values.set('metadata_tagging_enabled', 'true');
metadataMock.checkFfmpegAvailable.mockResolvedValue(true);
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
const taggedPath = `${sourcePath}.tmp`;
metadataMock.tagMultipleFiles.mockResolvedValue([
{ success: true, filePath: sourcePath, taggedFilePath: taggedPath },
]);
const expectedDir = path.join('/media', 'Author', 'Book');
const targetFile = path.join(expectedDir, 'book.m4b');
fsMock.access.mockImplementation(async (filePath: string) => {
const normalized = path.normalize(filePath);
if (normalized === path.normalize(taggedPath)) return undefined;
if (normalized === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockImplementation(async (src: string, dest: string) => {
// Tagged file copy fails with EPERM
if (path.normalize(src) === path.normalize(taggedPath)) {
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
}
// Original file copy succeeds
return undefined;
});
fsMock.chmod.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
}, '{author}/{title}',
{ jobId: 'job-fallback', context: 'test' }
);
expect(result.success).toBe(true);
expect(result.audioFiles).toEqual([targetFile]);
expect(result.filesMovedCount).toBe(1);
// Tagged temp file should be cleaned up
expect(fsMock.unlink).toHaveBeenCalledWith(taggedPath);
// Fallback copy should use the original source
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
// Should record that tagged copy failed
expect(result.errors.join(' ')).toContain('Tagged copy failed');
expect(result.errors.join(' ')).toContain('without metadata tags');
});
it('returns failure when tagged copy and fallback both fail', async () => {
configState.values.set('metadata_tagging_enabled', 'true');
metadataMock.checkFfmpegAvailable.mockResolvedValue(true);
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
const taggedPath = `${sourcePath}.tmp`;
metadataMock.tagMultipleFiles.mockResolvedValue([
{ success: true, filePath: sourcePath, taggedFilePath: taggedPath },
]);
fsMock.access.mockImplementation(async (filePath: string) => {
const normalized = path.normalize(filePath);
if (normalized === path.normalize(taggedPath)) return undefined;
if (normalized === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
// Both tagged and original copies fail
fsMock.copyFile.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
);
fsMock.unlink.mockResolvedValue(undefined);
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
}, '{author}/{title}',
{ jobId: 'job-both-fail', context: 'test' }
);
expect(result.success).toBe(false);
expect(result.audioFiles).toEqual([]);
expect(result.filesMovedCount).toBe(0);
expect(result.errors.join(' ')).toContain('EPERM');
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
// Should still clean up tagged temp file
expect(fsMock.unlink).toHaveBeenCalledWith(taggedPath);
});
it('reports partial success when some files copy and others fail', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['disc1.mp3', 'disc2.mp3'],
coverFile: undefined,
isFile: false,
});
const sourceRoot = path.normalize('/downloads/book');
const source1 = path.join('/downloads', 'book', 'disc1.mp3');
const source2 = path.join('/downloads', 'book', 'disc2.mp3');
const expectedDir = path.join('/media', 'Author', 'Book');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath).startsWith(sourceRoot)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockImplementation(async (src: string) => {
// First file succeeds, second fails
if (path.normalize(src) === path.normalize(source2)) {
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
}
return undefined;
});
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
}, '{author}/{title}');
// Should succeed because at least one file was copied
expect(result.success).toBe(true);
expect(result.audioFiles).toEqual([path.join(expectedDir, 'disc1.mp3')]);
expect(result.filesMovedCount).toBe(1);
expect(result.errors.join(' ')).toContain('Failed to copy disc2.mp3');
});
it('succeeds with cover art when audio files were copied', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
const expectedDir = path.join('/media', 'Author', 'Book');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') });
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
coverArtUrl: 'https://images.example/cover.jpg',
}, '{author}/{title}');
expect(result.success).toBe(true);
expect(result.audioFiles).toEqual([path.join(expectedDir, 'book.m4b')]);
expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg'));
});
it('returns failure even when cover art succeeds but audio copy fails', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
const expectedDir = path.join('/media', 'Author', 'Book');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
);
fsMock.writeFile.mockResolvedValue(undefined);
axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') });
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
coverArtUrl: 'https://images.example/cover.jpg',
}, '{author}/{title}');
// Audio copy failed → should be failure despite cover art being available
expect(result.success).toBe(false);
expect(result.audioFiles).toEqual([]);
expect(result.filesMovedCount).toBe(0);
expect(result.errors.join(' ')).toContain('EPERM');
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
});
});
+16
View File
@@ -41,12 +41,28 @@ describe('generateFilesHash', () => {
'/path/Chapter 04.mp4',
'/path/Chapter 05.aa',
'/path/Chapter 06.aax',
'/path/Chapter 07.flac',
'/path/Chapter 08.ogg',
];
const hash = generateFilesHash(filePaths);
expect(hash).toBeTruthy();
expect(hash.length).toBe(64);
});
it('should include FLAC files in hash generation', () => {
const withFlac = ['/path/Chapter 01.flac', '/path/Chapter 02.flac'];
const hash = generateFilesHash(withFlac);
expect(hash).toBeTruthy();
expect(hash.length).toBe(64);
});
it('should include OGG files in hash generation', () => {
const withOgg = ['/path/Chapter 01.ogg', '/path/Chapter 02.ogg'];
const hash = generateFilesHash(withOgg);
expect(hash).toBeTruthy();
expect(hash.length).toBe(64);
});
it('should filter out non-audio files', () => {
const filePaths = [
'/path/Chapter 01.mp3',
+202
View File
@@ -0,0 +1,202 @@
/**
* Component: Indexer Grouping Utils Tests
* Documentation: documentation/phase3/prowlarr.md
*/
import { describe, expect, it } from 'vitest';
import {
getCategoriesForType,
groupIndexersByCategories,
getGroupDescription,
IndexerConfig,
} from '@/lib/utils/indexer-grouping';
describe('getCategoriesForType', () => {
describe('audiobook', () => {
it('returns audiobookCategories when set', () => {
const indexer: IndexerConfig = { id: 1, name: 'Test', audiobookCategories: [3030, 3010] };
expect(getCategoriesForType(indexer, 'audiobook')).toEqual([3030, 3010]);
});
it('returns empty array when audiobookCategories is explicitly empty', () => {
const indexer: IndexerConfig = { id: 1, name: 'Test', audiobookCategories: [] };
expect(getCategoriesForType(indexer, 'audiobook')).toEqual([]);
});
it('falls back to legacy categories when audiobookCategories is undefined', () => {
const indexer: IndexerConfig = { id: 1, name: 'Test', categories: [3030, 3040] };
expect(getCategoriesForType(indexer, 'audiobook')).toEqual([3030, 3040]);
});
it('falls back to default [3030] when no fields are set', () => {
const indexer: IndexerConfig = { id: 1, name: 'Test' };
expect(getCategoriesForType(indexer, 'audiobook')).toEqual([3030]);
});
it('prefers audiobookCategories over legacy categories', () => {
const indexer: IndexerConfig = {
id: 1, name: 'Test',
audiobookCategories: [3010],
categories: [3030],
};
expect(getCategoriesForType(indexer, 'audiobook')).toEqual([3010]);
});
});
describe('ebook', () => {
it('returns ebookCategories when set', () => {
const indexer: IndexerConfig = { id: 1, name: 'Test', ebookCategories: [7020, 7050] };
expect(getCategoriesForType(indexer, 'ebook')).toEqual([7020, 7050]);
});
it('returns empty array when ebookCategories is explicitly empty', () => {
const indexer: IndexerConfig = { id: 1, name: 'Test', ebookCategories: [] };
expect(getCategoriesForType(indexer, 'ebook')).toEqual([]);
});
it('falls back to default [7020] when ebookCategories is undefined', () => {
const indexer: IndexerConfig = { id: 1, name: 'Test' };
expect(getCategoriesForType(indexer, 'ebook')).toEqual([7020]);
});
});
});
describe('groupIndexersByCategories', () => {
it('groups indexers with matching categories', () => {
const indexers: IndexerConfig[] = [
{ id: 1, name: 'A', audiobookCategories: [3030] },
{ id: 2, name: 'B', audiobookCategories: [3030] },
{ id: 3, name: 'C', audiobookCategories: [3030, 3010] },
];
const { groups, skippedIndexers } = groupIndexersByCategories(indexers, 'audiobook');
expect(groups).toHaveLength(2);
expect(skippedIndexers).toHaveLength(0);
const group3030 = groups.find(g => g.categories.length === 1 && g.categories[0] === 3030);
expect(group3030).toBeDefined();
expect(group3030!.indexerIds).toEqual([1, 2]);
const groupMulti = groups.find(g => g.categories.length === 2);
expect(groupMulti).toBeDefined();
expect(groupMulti!.indexerIds).toEqual([3]);
});
it('sorts categories for consistent grouping regardless of order', () => {
const indexers: IndexerConfig[] = [
{ id: 1, name: 'A', audiobookCategories: [3010, 3030] },
{ id: 2, name: 'B', audiobookCategories: [3030, 3010] },
];
const { groups } = groupIndexersByCategories(indexers, 'audiobook');
expect(groups).toHaveLength(1);
expect(groups[0].indexerIds).toEqual([1, 2]);
expect(groups[0].categories).toEqual([3010, 3030]);
});
it('skips indexers with empty categories for the requested type', () => {
const indexers: IndexerConfig[] = [
{ id: 1, name: 'Active', audiobookCategories: [3030], ebookCategories: [7020] },
{ id: 2, name: 'Disabled', audiobookCategories: [], ebookCategories: [7020] },
{ id: 3, name: 'Also Active', audiobookCategories: [3030], ebookCategories: [] },
];
// Audiobook search: indexer 2 is skipped
const audioResult = groupIndexersByCategories(indexers, 'audiobook');
expect(audioResult.groups).toHaveLength(1);
expect(audioResult.groups[0].indexerIds).toEqual([1, 3]);
expect(audioResult.skippedIndexers).toHaveLength(1);
expect(audioResult.skippedIndexers[0].id).toBe(2);
// Ebook search: indexer 3 is skipped
const ebookResult = groupIndexersByCategories(indexers, 'ebook');
expect(ebookResult.groups).toHaveLength(1);
expect(ebookResult.groups[0].indexerIds).toEqual([1, 2]);
expect(ebookResult.skippedIndexers).toHaveLength(1);
expect(ebookResult.skippedIndexers[0].id).toBe(3);
});
it('returns empty groups when all indexers are disabled for the type', () => {
const indexers: IndexerConfig[] = [
{ id: 1, name: 'A', audiobookCategories: [] },
{ id: 2, name: 'B', audiobookCategories: [] },
];
const { groups, skippedIndexers } = groupIndexersByCategories(indexers, 'audiobook');
expect(groups).toHaveLength(0);
expect(skippedIndexers).toHaveLength(2);
});
it('handles legacy configs without audiobookCategories field', () => {
const indexers: IndexerConfig[] = [
{ id: 1, name: 'Legacy', categories: [3030] },
{ id: 2, name: 'New', audiobookCategories: [3030] },
];
const { groups, skippedIndexers } = groupIndexersByCategories(indexers, 'audiobook');
expect(groups).toHaveLength(1);
expect(groups[0].indexerIds).toEqual([1, 2]);
expect(skippedIndexers).toHaveLength(0);
});
it('defaults to audiobook type when not specified', () => {
const indexers: IndexerConfig[] = [
{ id: 1, name: 'Test', audiobookCategories: [3030], ebookCategories: [7020] },
];
const { groups } = groupIndexersByCategories(indexers);
expect(groups).toHaveLength(1);
expect(groups[0].categories).toEqual([3030]);
});
it('handles custom category IDs', () => {
const indexers: IndexerConfig[] = [
{ id: 1, name: 'A', audiobookCategories: [3030, 99999] },
{ id: 2, name: 'B', audiobookCategories: [3030, 99999] },
{ id: 3, name: 'C', audiobookCategories: [3030] },
];
const { groups } = groupIndexersByCategories(indexers, 'audiobook');
expect(groups).toHaveLength(2);
const customGroup = groups.find(g => g.categories.includes(99999));
expect(customGroup).toBeDefined();
expect(customGroup!.indexerIds).toEqual([1, 2]);
});
it('handles empty indexer array', () => {
const { groups, skippedIndexers } = groupIndexersByCategories([], 'audiobook');
expect(groups).toHaveLength(0);
expect(skippedIndexers).toHaveLength(0);
});
});
describe('getGroupDescription', () => {
it('returns human-readable description', () => {
const description = getGroupDescription({
categories: [3030, 3010],
indexerIds: [1, 2],
indexers: [
{ id: 1, name: 'Indexer A' },
{ id: 2, name: 'Indexer B' },
],
});
expect(description).toBe('2 indexers (Indexer A, Indexer B) with categories [3030, 3010]');
});
it('uses singular for single indexer', () => {
const description = getGroupDescription({
categories: [3030],
indexerIds: [1],
indexers: [{ id: 1, name: 'Solo' }],
});
expect(description).toBe('1 indexer (Solo) with categories [3030]');
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Utility: Permission Resolution Tests
* Documentation: documentation/admin-dashboard.md
*/
import { describe, expect, it } from 'vitest';
import { resolvePermission } from '@/lib/utils/permissions';
describe('resolvePermission', () => {
it('always grants permission for admins regardless of other settings', () => {
expect(resolvePermission('admin', null, false)).toBe(true);
expect(resolvePermission('admin', false, false)).toBe(true);
expect(resolvePermission('admin', true, false)).toBe(true);
expect(resolvePermission('admin', null, true)).toBe(true);
});
it('uses per-user setting when explicitly true', () => {
expect(resolvePermission('user', true, false)).toBe(true);
expect(resolvePermission('user', true, true)).toBe(true);
});
it('uses per-user setting when explicitly false', () => {
expect(resolvePermission('user', false, true)).toBe(false);
expect(resolvePermission('user', false, false)).toBe(false);
});
it('falls back to global setting when per-user is null', () => {
expect(resolvePermission('user', null, true)).toBe(true);
expect(resolvePermission('user', null, false)).toBe(false);
});
});
+163
View File
@@ -116,6 +116,25 @@ describe('ranking-algorithm', () => {
expect(highSeeders.some((note: string) => note.includes('Excellent availability'))).toBe(true);
});
it('adds lossless format note for FLAC files', () => {
const algorithm = new RankingAlgorithm();
const breakdown = {
formatScore: 0,
sizeScore: 0,
seederScore: 0,
matchScore: 50,
totalScore: 50,
notes: [],
};
const flacNotes = (algorithm as any).generateNotes(
{ ...baseTorrent, format: 'FLAC', title: 'Book Title [FLAC]' },
breakdown,
60
);
expect(flacNotes.some((note: string) => note.includes('Lossless format'))).toBe(true);
});
it('adds format and size quality notes for MP3 files', () => {
const algorithm = new RankingAlgorithm();
const breakdown = {
@@ -214,6 +233,113 @@ describe('ranking-algorithm', () => {
});
});
describe('Colon-Separated Subtitle/Series Handling', () => {
const algorithm = new RankingAlgorithm();
it('matches "The Finest Edge of Twilight: Dungeons & Dragons" when torrent omits series', () => {
const torrent = {
...baseTorrent,
title: 'The Finest Edge of Twilight by R A Salvatore [ENG / M4B]',
seeders: 129,
size: 650 * MB,
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'The Finest Edge of Twilight: Dungeons & Dragons',
author: 'R.A. Salvatore',
});
// Should pass word coverage (required: "finest", "edge", "twilight" — "dungeons" and "dragons" are optional)
// Should NOT get 0 match score
expect(breakdown.matchScore).toBeGreaterThan(0);
expect(breakdown.matchScore).toBeGreaterThan(40);
});
it('matches when torrent includes the colon subtitle', () => {
const torrent = {
...baseTorrent,
title: 'The Finest Edge of Twilight Dungeons and Dragons by R A Salvatore [M4B]',
seeders: 50,
size: 650 * MB,
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'The Finest Edge of Twilight: Dungeons & Dragons',
author: 'R.A. Salvatore',
});
// Should still match when torrent has the full title including subtitle
expect(breakdown.matchScore).toBeGreaterThan(0);
});
it('matches "Project Hail Mary: A Novel" when torrent has just the title', () => {
const torrent = {
...baseTorrent,
title: 'Andy Weir - Project Hail Mary [M4B]',
seeders: 100,
size: 400 * MB,
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Project Hail Mary: A Novel',
author: 'Andy Weir',
});
// "A Novel" after colon should be optional
expect(breakdown.matchScore).toBeGreaterThan(40);
});
it('matches title with both colon subtitle and parenthetical content', () => {
const torrent = {
...baseTorrent,
title: 'Author Name - Book Title [Unabridged]',
seeders: 50,
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Book Title: Series Name (Book 1)',
author: 'Author Name',
});
// Both ": Series Name" and "(Book 1)" should be optional
expect(breakdown.matchScore).toBeGreaterThan(0);
});
it('does not treat colon at start of title as optional split', () => {
const torrent = {
...baseTorrent,
title: 'Author - Some Title',
seeders: 50,
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Some Title',
author: 'Author',
});
// Normal match, no colon involved
expect(breakdown.matchScore).toBeGreaterThan(40);
});
it('handles "Re:Zero" style titles where colon is part of the word', () => {
const torrent = {
...baseTorrent,
title: 'Author - Re Zero Starting Life in Another World',
seeders: 50,
size: 500 * MB,
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Re:Zero - Starting Life in Another World',
author: 'Author',
});
// "Re" is required, "Zero - Starting Life in Another World" is optional after colon
// But the torrent still has all the words so it should score reasonably
expect(breakdown.matchScore).toBeGreaterThan(0);
});
});
describe('Structured Metadata Prefix Handling', () => {
const algorithm = new RankingAlgorithm();
@@ -758,6 +884,43 @@ describe('ranking-algorithm', () => {
expect(breakdown.formatScore).toBe(4);
});
it('detects FLAC format from title', () => {
const torrent = { ...baseTorrent, title: 'Book Title [FLAC]' };
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Book Title',
author: 'Author',
});
expect(breakdown.formatScore).toBe(7);
});
it('scores FLAC between M4B and M4A', () => {
const flacTorrent = { ...baseTorrent, title: 'Book Title [FLAC]' };
const m4bTorrent = { ...baseTorrent, title: 'Book Title [M4B]' };
const m4aTorrent = { ...baseTorrent, title: 'Book Title [M4A]' };
const flacBreakdown = algorithm.getScoreBreakdown(flacTorrent, { title: 'Book Title', author: 'Author' });
const m4bBreakdown = algorithm.getScoreBreakdown(m4bTorrent, { title: 'Book Title', author: 'Author' });
const m4aBreakdown = algorithm.getScoreBreakdown(m4aTorrent, { title: 'Book Title', author: 'Author' });
expect(m4bBreakdown.formatScore).toBeGreaterThan(flacBreakdown.formatScore);
expect(flacBreakdown.formatScore).toBeGreaterThan(m4aBreakdown.formatScore);
});
it('uses explicit FLAC format field when provided', () => {
const torrent = {
...baseTorrent,
title: 'Book Title',
format: 'FLAC' as const,
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Book Title',
author: 'Author',
});
expect(breakdown.formatScore).toBe(7);
});
it('uses explicit format field when provided', () => {
const torrent = {
...baseTorrent,
+20
View File
@@ -11,6 +11,8 @@ import {
getChildIds,
getParentId,
isParentCategory,
getAllStandardCategoryIds,
isStandardCategory,
} from '@/lib/utils/torrent-categories';
describe('torrent categories', () => {
@@ -39,4 +41,22 @@ describe('torrent categories', () => {
expect(DEFAULT_CATEGORIES).toEqual([3030]);
expect(TORRENT_CATEGORIES.length).toBeGreaterThan(0);
});
it('returns all standard category IDs including parents and children', () => {
const ids = getAllStandardCategoryIds();
expect(ids.has(3000)).toBe(true); // parent
expect(ids.has(3030)).toBe(true); // child
expect(ids.has(7020)).toBe(true); // child
expect(ids.has(8000)).toBe(true); // parent with no children
expect(ids.has(99999)).toBe(false); // not a standard category
});
it('identifies standard vs custom categories', () => {
expect(isStandardCategory(3000)).toBe(true);
expect(isStandardCategory(3030)).toBe(true);
expect(isStandardCategory(7020)).toBe(true);
expect(isStandardCategory(8000)).toBe(true);
expect(isStandardCategory(12345)).toBe(false);
expect(isStandardCategory(0)).toBe(false);
});
});