mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ const settingsFixture = {
|
||||
downloadDir: '',
|
||||
mediaDir: '',
|
||||
audiobookPathTemplate: '',
|
||||
ebookPathTemplate: '',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user