mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
4b90b35748
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.
397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
/**
|
|
* Component: Admin Settings Test API Route Tests
|
|
* Documentation: documentation/testing.md
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createPrismaMock } from '../helpers/prisma';
|
|
|
|
let authRequest: any;
|
|
|
|
const prismaMock = createPrismaMock();
|
|
const requireAuthMock = vi.hoisted(() => vi.fn());
|
|
const requireAdminMock = vi.hoisted(() => vi.fn());
|
|
const plexServiceMock = vi.hoisted(() => ({
|
|
testConnection: vi.fn(),
|
|
getLibraries: vi.fn(),
|
|
}));
|
|
const prowlarrMock = vi.hoisted(() => ({
|
|
getIndexers: vi.fn(),
|
|
}));
|
|
const qbtMock = vi.hoisted(() => ({
|
|
testConnectionWithCredentials: vi.fn(),
|
|
}));
|
|
const sabnzbdMock = vi.hoisted(() => ({
|
|
testConnection: vi.fn(),
|
|
}));
|
|
const maskedValue = '\u2022\u2022\u2022\u2022';
|
|
const testFlareSolverrMock = vi.hoisted(() => vi.fn());
|
|
const fsMock = vi.hoisted(() => ({
|
|
access: vi.fn(),
|
|
constants: { R_OK: 4 },
|
|
}));
|
|
const configServiceMock = vi.hoisted(() => ({
|
|
get: vi.fn(),
|
|
}));
|
|
const downloadClientManagerMock = vi.hoisted(() => ({
|
|
getAllClients: vi.fn(),
|
|
testConnection: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/db', () => ({
|
|
prisma: prismaMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/middleware/auth', () => ({
|
|
requireAuth: requireAuthMock,
|
|
requireAdmin: requireAdminMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/integrations/plex.service', () => ({
|
|
getPlexService: () => plexServiceMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/integrations/prowlarr.service', () => ({
|
|
ProwlarrService: class {
|
|
constructor() {}
|
|
getIndexers = prowlarrMock.getIndexers;
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
|
QBittorrentService: {
|
|
testConnectionWithCredentials: qbtMock.testConnectionWithCredentials,
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
|
SABnzbdService: class {
|
|
constructor() {}
|
|
testConnection = sabnzbdMock.testConnection;
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/lib/integrations/transmission.service', () => ({
|
|
TransmissionService: class {
|
|
constructor() {}
|
|
testConnection = vi.fn();
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/lib/services/ebook-scraper', () => ({
|
|
testFlareSolverrConnection: testFlareSolverrMock,
|
|
}));
|
|
|
|
vi.mock('fs/promises', () => ({
|
|
default: fsMock,
|
|
...fsMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/config.service', () => ({
|
|
getConfigService: () => configServiceMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
|
getDownloadClientManager: () => downloadClientManagerMock,
|
|
}));
|
|
|
|
describe('Admin settings test routes', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() };
|
|
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
|
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
|
fsMock.access.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('tests Plex connection with stored token', async () => {
|
|
prismaMock.configuration.findUnique.mockResolvedValueOnce({ value: 'token' });
|
|
plexServiceMock.testConnection.mockResolvedValueOnce({ success: true, info: { platform: 'Plex', version: '1.0' } });
|
|
plexServiceMock.getLibraries.mockResolvedValueOnce([{ id: '1', title: 'Books', type: 'book' }]);
|
|
|
|
const request = { json: vi.fn().mockResolvedValue({ url: 'http://plex', token: '********' }) };
|
|
const { POST } = await import('@/app/api/admin/settings/test-plex/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(payload.success).toBe(true);
|
|
});
|
|
|
|
it('rejects Plex test when URL or token is missing', async () => {
|
|
const request = { json: vi.fn().mockResolvedValue({ url: '', token: 'token' }) };
|
|
const { POST } = await import('@/app/api/admin/settings/test-plex/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/URL and token are required/);
|
|
});
|
|
|
|
it('rejects Plex test when masked token is missing in storage', async () => {
|
|
prismaMock.configuration.findUnique.mockResolvedValueOnce(null);
|
|
const request = { json: vi.fn().mockResolvedValue({ url: 'http://plex', token: maskedValue }) };
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/test-plex/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/No stored token/);
|
|
});
|
|
|
|
it('returns error when Plex connection test fails', async () => {
|
|
plexServiceMock.testConnection.mockResolvedValueOnce({ success: false, message: 'bad token' });
|
|
const request = { json: vi.fn().mockResolvedValue({ url: 'http://plex', token: 'token' }) };
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/test-plex/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/bad token/);
|
|
});
|
|
|
|
it('tests Prowlarr connection', async () => {
|
|
prowlarrMock.getIndexers.mockResolvedValueOnce([{ id: 1, name: 'Indexer', protocol: 'torrent', enable: true }]);
|
|
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) };
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(payload.success).toBe(true);
|
|
});
|
|
|
|
it('rejects Prowlarr test when URL or API key is missing', async () => {
|
|
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr' }) };
|
|
const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/URL and API key are required/);
|
|
});
|
|
|
|
it('rejects masked Prowlarr API key when no stored key exists', async () => {
|
|
prismaMock.configuration.findUnique.mockResolvedValueOnce(null);
|
|
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: maskedValue }) };
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/No stored API key/);
|
|
});
|
|
|
|
it('returns error when Prowlarr test fails', async () => {
|
|
prowlarrMock.getIndexers.mockRejectedValueOnce(new Error('prowlarr down'));
|
|
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) };
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(payload.error).toMatch(/prowlarr down/);
|
|
});
|
|
|
|
it('tests download client connection', async () => {
|
|
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' }),
|
|
};
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(payload.success).toBe(true);
|
|
expect(payload.message).toContain('4.0.0');
|
|
});
|
|
|
|
it('validates required fields for download client testing', async () => {
|
|
const request = { json: vi.fn().mockResolvedValue({ url: 'http://qbt' }) };
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/Type and URL are required/);
|
|
});
|
|
|
|
it('rejects invalid download client types', async () => {
|
|
const request = { json: vi.fn().mockResolvedValue({ type: 'invalid', url: 'http://qbt' }) };
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/Invalid client type/);
|
|
});
|
|
|
|
it('uses stored password when masked password is provided', async () => {
|
|
// Mock download client manager to return the stored password
|
|
downloadClientManagerMock.getAllClients.mockResolvedValueOnce([
|
|
{ type: 'qbittorrent', password: 'stored-pass' },
|
|
]);
|
|
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
|
|
success: true,
|
|
message: 'Successfully connected to qbittorrent (v4.1.0)',
|
|
});
|
|
const request = {
|
|
json: vi.fn().mockResolvedValue({
|
|
type: 'qbittorrent',
|
|
url: 'http://qbt',
|
|
username: 'user',
|
|
password: '\u2022\u2022\u2022\u2022',
|
|
}),
|
|
};
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(payload.success).toBe(true);
|
|
});
|
|
|
|
it('returns error when masked password is missing in storage', async () => {
|
|
// Mock download client manager to return no matching client
|
|
downloadClientManagerMock.getAllClients.mockResolvedValueOnce([]);
|
|
const request = {
|
|
json: vi.fn().mockResolvedValue({
|
|
type: 'qbittorrent',
|
|
url: 'http://qbt',
|
|
username: 'user',
|
|
password: '\u2022\u2022\u2022\u2022',
|
|
}),
|
|
};
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/No stored password/);
|
|
});
|
|
|
|
it('returns error when SABnzbd connection fails', async () => {
|
|
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
|
|
success: false,
|
|
message: 'bad key',
|
|
});
|
|
const request = {
|
|
json: vi.fn().mockResolvedValue({ type: 'sabnzbd', url: 'http://sab', password: 'key' }),
|
|
};
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/bad key/);
|
|
});
|
|
|
|
it('requires path mapping values when enabled', async () => {
|
|
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
|
|
success: true,
|
|
message: 'Connected',
|
|
});
|
|
const request = {
|
|
json: vi.fn().mockResolvedValue({
|
|
type: 'qbittorrent',
|
|
url: 'http://qbt',
|
|
username: 'user',
|
|
password: 'pass',
|
|
remotePathMappingEnabled: true,
|
|
}),
|
|
};
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/Remote path and local path are required/);
|
|
});
|
|
|
|
it('rejects inaccessible local path when mapping is enabled', async () => {
|
|
downloadClientManagerMock.testConnection.mockResolvedValueOnce({
|
|
success: true,
|
|
message: 'Connected',
|
|
});
|
|
fsMock.access.mockRejectedValueOnce(new Error('missing'));
|
|
const request = {
|
|
json: vi.fn().mockResolvedValue({
|
|
type: 'qbittorrent',
|
|
url: 'http://qbt',
|
|
username: 'user',
|
|
password: 'pass',
|
|
remotePathMappingEnabled: true,
|
|
remotePath: '/remote',
|
|
localPath: '/local',
|
|
}),
|
|
};
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/not accessible/);
|
|
});
|
|
|
|
it('tests FlareSolverr connection', async () => {
|
|
testFlareSolverrMock.mockResolvedValueOnce({ success: true });
|
|
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.li' }) };
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(payload.success).toBe(true);
|
|
expect(testFlareSolverrMock).toHaveBeenCalledWith('http://flare', 'https://annas-archive.li');
|
|
});
|
|
|
|
it('rejects FlareSolverr test when URL is missing', async () => {
|
|
const request = { json: vi.fn().mockResolvedValue({}) };
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/URL is required/);
|
|
});
|
|
|
|
it('rejects FlareSolverr test when URL has invalid scheme', async () => {
|
|
const request = { json: vi.fn().mockResolvedValue({ url: 'ftp://flare' }) };
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/must start with http/);
|
|
});
|
|
|
|
it('returns error when FlareSolverr test throws', async () => {
|
|
testFlareSolverrMock.mockRejectedValueOnce(new Error('flare down'));
|
|
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.li' }) };
|
|
|
|
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(payload.message).toMatch(/flare down/);
|
|
});
|
|
});
|
|
|
|
|