Add backend unit test framework and modularize settings UI

Introduced a Vitest-based backend unit testing framework with supporting scripts, helpers, and GitHub Actions integration. Refactored the admin settings page to a modular architecture, splitting monolithic logic into feature-specific tabs and hooks for improved maintainability and testability. Updated documentation to reflect the new testing setup and settings architecture, and added new dependencies for testing utilities.
This commit is contained in:
kikootwo
2026-01-15 16:49:59 -05:00
parent b3f89d67bb
commit 94dbaf073b
127 changed files with 23549 additions and 2868 deletions
@@ -0,0 +1,62 @@
/**
* Component: Admin Backend Mode API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const configServiceMock = vi.hoisted(() => ({
getBackendMode: vi.fn(),
setMany: vi.fn(),
}));
const clearLibraryServiceCacheMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/services/config.service', () => ({
ConfigurationService: class {
getBackendMode = configServiceMock.getBackendMode;
setMany = configServiceMock.setMany;
},
}));
vi.mock('@/lib/services/library', () => ({
clearLibraryServiceCache: clearLibraryServiceCacheMock,
}));
describe('Admin backend mode 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());
});
it('returns backend mode', async () => {
configServiceMock.getBackendMode.mockResolvedValue('plex');
const { GET } = await import('@/app/api/admin/backend-mode/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.backendMode).toBe('plex');
});
it('updates backend mode and clears cache', async () => {
const { PUT } = await import('@/app/api/admin/backend-mode/route');
const response = await PUT({ json: vi.fn().mockResolvedValue({ mode: 'audiobookshelf' }) } as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(clearLibraryServiceCacheMock).toHaveBeenCalled();
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* Component: Admin BookDate 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());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
describe('Admin BookDate toggle route', () => {
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(authRequest));
});
it('toggles BookDate enabled state', async () => {
authRequest.json.mockResolvedValue({ isEnabled: true });
prismaMock.bookDateConfig.updateMany.mockResolvedValue({ count: 1 });
const { PATCH } = await import('@/app/api/admin/bookdate/toggle/route');
const response = await PATCH({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.isEnabled).toBe(true);
expect(prismaMock.bookDateConfig.updateMany).toHaveBeenCalledWith({ data: { isEnabled: true } });
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Component: Admin Downloads 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 configServiceMock = vi.hoisted(() => ({ get: vi.fn() }));
const qbittorrentMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
getQBittorrentService: async () => qbittorrentMock,
}));
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
getSABnzbdService: async () => ({ getNZB: vi.fn() }),
}));
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());
});
it('returns formatted active downloads', async () => {
prismaMock.request.findMany.mockResolvedValueOnce([
{
id: 'req-1',
status: 'downloading',
progress: 50,
updatedAt: new Date(),
audiobook: { title: 'Title', author: 'Author' },
user: { plexUsername: 'user' },
downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading' }],
},
]);
configServiceMock.get.mockResolvedValueOnce('qbittorrent');
qbittorrentMock.getTorrent.mockResolvedValueOnce({ dlspeed: 123, eta: 60 });
const { GET } = await import('@/app/api/admin/downloads/active/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.downloads[0].speed).toBe(123);
expect(payload.downloads[0].torrentName).toBe('Torrent');
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Component: Admin Job Status API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const verifyAccessTokenMock = vi.hoisted(() => vi.fn());
const jobQueueMock = vi.hoisted(() => ({ getJob: vi.fn() }));
vi.mock('@/lib/utils/jwt', () => ({
verifyAccessToken: verifyAccessTokenMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
const makeRequest = (token?: string) => ({
headers: {
get: (key: string) => (key.toLowerCase() === 'authorization' ? token : null),
},
});
describe('Admin job status route', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('rejects missing authorization', async () => {
const { GET } = await import('@/app/api/admin/job-status/[id]/route');
const response = await GET(makeRequest() as any, { params: Promise.resolve({ id: '1' }) });
const payload = await response.json();
expect(response.status).toBe(401);
expect(payload.error).toBe('Unauthorized');
});
it('returns job status for admin token', async () => {
verifyAccessTokenMock.mockReturnValue({ role: 'admin' });
jobQueueMock.getJob.mockResolvedValue({
id: '1',
type: 'search',
status: 'completed',
createdAt: new Date(),
startedAt: null,
completedAt: null,
result: null,
errorMessage: null,
attempts: 1,
maxAttempts: 3,
});
const { GET } = await import('@/app/api/admin/job-status/[id]/route');
const response = await GET(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: '1' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.job.status).toBe('completed');
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* Component: Admin Jobs API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const verifyAccessTokenMock = vi.hoisted(() => vi.fn());
const schedulerMock = vi.hoisted(() => ({
getScheduledJobs: vi.fn(),
createScheduledJob: vi.fn(),
updateScheduledJob: vi.fn(),
deleteScheduledJob: vi.fn(),
triggerJobNow: vi.fn(),
}));
vi.mock('@/lib/utils/jwt', () => ({
verifyAccessToken: verifyAccessTokenMock,
}));
vi.mock('@/lib/services/scheduler.service', () => ({
getSchedulerService: () => schedulerMock,
}));
const makeRequest = (token?: string, body?: any) => ({
headers: {
get: (key: string) => (key.toLowerCase() === 'authorization' ? token : null),
},
json: vi.fn().mockResolvedValue(body || {}),
});
describe('Admin jobs routes', () => {
beforeEach(() => {
vi.clearAllMocks();
verifyAccessTokenMock.mockReturnValue({ role: 'admin' });
});
it('lists scheduled jobs', async () => {
schedulerMock.getScheduledJobs.mockResolvedValue([{ id: 'job-1' }]);
const { GET } = await import('@/app/api/admin/jobs/route');
const response = await GET(makeRequest('Bearer token') as any);
const payload = await response.json();
expect(payload.jobs).toHaveLength(1);
});
it('creates a scheduled job', async () => {
schedulerMock.createScheduledJob.mockResolvedValue({ id: 'job-2' });
const { POST } = await import('@/app/api/admin/jobs/route');
const response = await POST(makeRequest('Bearer token', { name: 'Job', type: 'type', schedule: '* * * * *' }) as any);
const payload = await response.json();
expect(payload.job.id).toBe('job-2');
});
it('updates a scheduled job', async () => {
schedulerMock.updateScheduledJob.mockResolvedValue({ id: 'job-3' });
const { PUT } = await import('@/app/api/admin/jobs/[id]/route');
const response = await PUT(makeRequest('Bearer token', { name: 'Job' }) as any, { params: Promise.resolve({ id: 'job-3' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
});
it('deletes a scheduled job', async () => {
schedulerMock.deleteScheduledJob.mockResolvedValue(undefined);
const { DELETE } = await import('@/app/api/admin/jobs/[id]/route');
const response = await DELETE(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'job-4' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
});
it('triggers a scheduled job', async () => {
schedulerMock.triggerJobNow.mockResolvedValue('job-5');
const { POST } = await import('@/app/api/admin/jobs/[id]/trigger/route');
const response = await POST(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'job-5' }) });
const payload = await response.json();
expect(payload.jobId).toBe('job-5');
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Component: Admin Logs 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());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
describe('Admin logs 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());
});
it('returns paginated logs', async () => {
prismaMock.job.findMany.mockResolvedValueOnce([{ id: 'job-1' }]);
prismaMock.job.count.mockResolvedValueOnce(1);
const { GET } = await import('@/app/api/admin/logs/route');
const response = await GET({ url: 'http://localhost/api/admin/logs?page=1&limit=10' } as any);
const payload = await response.json();
expect(payload.logs).toHaveLength(1);
expect(payload.pagination.total).toBe(1);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Component: Admin Metrics 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());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
describe('Admin metrics 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());
});
it('returns metrics and system health', async () => {
prismaMock.request.count
.mockResolvedValueOnce(10)
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(5)
.mockResolvedValueOnce(1)
.mockResolvedValueOnce(0);
prismaMock.user.count.mockResolvedValueOnce(3);
prismaMock.$queryRaw.mockResolvedValueOnce(1);
const { GET } = await import('@/app/api/admin/metrics/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.totalRequests).toBe(10);
expect(payload.systemHealth.status).toBe('healthy');
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* Component: Admin Plex API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const scanPlexMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/processors/scan-plex.processor', () => ({
processScanPlex: scanPlexMock,
}));
describe('Admin Plex scan 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());
});
it('triggers a Plex scan', async () => {
scanPlexMock.mockResolvedValue({ scanned: 10 });
const { POST } = await import('@/app/api/admin/plex/scan/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(scanPlexMock).toHaveBeenCalled();
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Component: Admin Requests 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 deleteRequestMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/services/request-delete.service', () => ({
deleteRequest: deleteRequestMock,
}));
describe('Admin requests routes', () => {
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());
});
it('returns recent requests', async () => {
prismaMock.request.findMany.mockResolvedValueOnce([
{
id: 'req-1',
status: 'pending',
createdAt: new Date(),
completedAt: null,
errorMessage: null,
audiobook: { title: 'Title', author: 'Author' },
user: { plexUsername: 'user' },
downloadHistory: [{ torrentUrl: 'http://torrent' }],
},
]);
const { GET } = await import('@/app/api/admin/requests/recent/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.requests).toHaveLength(1);
expect(payload.requests[0].torrentUrl).toBe('http://torrent');
});
it('soft deletes a request via delete service', async () => {
deleteRequestMock.mockResolvedValueOnce({
success: true,
message: 'Deleted',
filesDeleted: 1,
torrentsRemoved: 0,
torrentsKeptSeeding: 0,
torrentsKeptUnlimited: 0,
});
const { DELETE } = await import('@/app/api/admin/requests/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(deleteRequestMock).toHaveBeenCalledWith('req-1', 'admin-1');
});
});
@@ -0,0 +1,225 @@
/**
* Component: Admin Settings Core 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 configServiceMock = vi.hoisted(() => ({
setMany: vi.fn(),
clearCache: vi.fn(),
}));
const audibleServiceMock = vi.hoisted(() => ({
forceReinitialize: vi.fn(),
}));
const jobQueueMock = vi.hoisted(() => ({
addAudibleRefreshJob: vi.fn(),
}));
const plexServiceMock = vi.hoisted(() => ({
testConnection: vi.fn(),
}));
const pathMapperMock = vi.hoisted(() => ({
validate: vi.fn(),
}));
const invalidateQbMock = vi.hoisted(() => vi.fn());
const invalidateSabMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => audibleServiceMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/integrations/plex.service', () => ({
getPlexService: () => plexServiceMock,
}));
vi.mock('@/lib/utils/path-mapper', () => ({
PathMapper: pathMapperMock,
}));
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
invalidateQBittorrentService: invalidateQbMock,
}));
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
invalidateSABnzbdService: invalidateSabMock,
}));
describe('Admin settings core 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());
});
it('returns settings payload', async () => {
prismaMock.configuration.findMany.mockResolvedValueOnce([
{ key: 'plex_url', value: 'http://plex' },
{ key: 'plex_token', value: 'token' },
{ key: 'system.backend_mode', value: 'plex' },
]);
prismaMock.user.count.mockResolvedValueOnce(0);
const { GET } = await import('@/app/api/admin/settings/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.plex.url).toBe('http://plex');
expect(payload.backendMode).toBe('plex');
});
it('updates Plex settings', async () => {
plexServiceMock.testConnection.mockResolvedValue({ success: true, info: { machineIdentifier: 'machine' } });
const request = {
json: vi.fn().mockResolvedValue({
url: 'http://plex',
token: 'token',
libraryId: 'lib',
triggerScanAfterImport: true,
}),
};
const { PUT } = await import('@/app/api/admin/settings/plex/route');
const response = await PUT(request as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.configuration.upsert).toHaveBeenCalled();
});
it('updates download client settings', async () => {
const request = {
json: vi.fn().mockResolvedValue({
type: 'qbittorrent',
url: 'http://qbt',
username: 'user',
password: 'pass',
remotePathMappingEnabled: false,
}),
};
const { PUT } = await import('@/app/api/admin/settings/download-client/route');
const response = await PUT(request as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(invalidateQbMock).toHaveBeenCalled();
});
it('updates paths settings', async () => {
const request = {
json: vi.fn().mockResolvedValue({
downloadDir: '/downloads',
mediaDir: '/media',
metadataTaggingEnabled: true,
chapterMergingEnabled: false,
}),
};
const { PUT } = await import('@/app/api/admin/settings/paths/route');
const response = await PUT(request as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(invalidateQbMock).toHaveBeenCalled();
});
it('updates Prowlarr settings', async () => {
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) };
const { PUT } = await import('@/app/api/admin/settings/prowlarr/route');
const response = await PUT(request as any);
const payload = await response.json();
expect(payload.success).toBe(true);
});
it('updates registration settings', async () => {
const request = { json: vi.fn().mockResolvedValue({ enabled: true, requireAdminApproval: false }) };
const { PUT } = await import('@/app/api/admin/settings/registration/route');
const response = await PUT(request as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(configServiceMock.setMany).toHaveBeenCalled();
});
it('updates OIDC settings', async () => {
const request = { json: vi.fn().mockResolvedValue({ enabled: true, providerName: 'OIDC', clientSecret: 'secret' }) };
const { PUT } = await import('@/app/api/admin/settings/oidc/route');
const response = await PUT(request as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(configServiceMock.setMany).toHaveBeenCalled();
});
it('updates ebook settings', async () => {
const request = {
json: vi.fn().mockResolvedValue({ enabled: true, format: 'epub', baseUrl: 'https://annas-archive.li' }),
};
const { PUT } = await import('@/app/api/admin/settings/ebook/route');
const response = await PUT(request as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(configServiceMock.setMany).toHaveBeenCalled();
});
it('updates Audible region and triggers refresh', async () => {
const request = { json: vi.fn().mockResolvedValue({ region: 'us' }) };
const { PUT } = await import('@/app/api/admin/settings/audible/route');
const response = await PUT(request as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(jobQueueMock.addAudibleRefreshJob).toHaveBeenCalled();
});
it('updates Audiobookshelf settings', async () => {
const request = {
json: vi.fn().mockResolvedValue({
serverUrl: 'http://abs',
apiToken: 'token',
libraryId: 'lib',
triggerScanAfterImport: true,
}),
};
const { PUT } = await import('@/app/api/admin/settings/audiobookshelf/route');
const response = await PUT(request as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(configServiceMock.setMany).toHaveBeenCalled();
});
});
@@ -0,0 +1,77 @@
/**
* Component: Admin Settings Libraries API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const plexServiceMock = vi.hoisted(() => ({ getLibraries: vi.fn() }));
const configServiceMock = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/integrations/plex.service', () => ({
getPlexService: async () => plexServiceMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
describe('Admin settings libraries routes', () => {
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());
});
it('returns Plex libraries', async () => {
configServiceMock.get
.mockResolvedValueOnce('http://plex')
.mockResolvedValueOnce('token');
plexServiceMock.getLibraries.mockResolvedValueOnce([
{ key: '1', title: 'Audiobooks', type: 'artist' },
{ key: '2', title: 'Movies', type: 'movie' },
]);
const { GET } = await import('@/app/api/admin/settings/plex/libraries/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.libraries).toHaveLength(1);
});
it('returns Audiobookshelf libraries', async () => {
configServiceMock.get
.mockResolvedValueOnce('http://abs')
.mockResolvedValueOnce('token');
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
libraries: [
{ id: '1', name: 'Books', mediaType: 'book', stats: { totalItems: 10 } },
{ id: '2', name: 'Music', mediaType: 'music' },
],
}),
});
vi.stubGlobal('fetch', fetchMock);
const { GET } = await import('@/app/api/admin/settings/audiobookshelf/libraries/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.libraries).toHaveLength(1);
expect(payload.libraries[0].id).toBe('1');
});
});
@@ -0,0 +1,69 @@
/**
* Component: Admin Prowlarr Indexers API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const prowlarrMock = vi.hoisted(() => ({
getIndexers: vi.fn(),
}));
const configServiceMock = vi.hoisted(() => ({
get: vi.fn(),
setMany: vi.fn(),
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/integrations/prowlarr.service', () => ({
getProwlarrService: async () => prowlarrMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
describe('Admin Prowlarr indexers route', () => {
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());
});
it('returns indexers with saved config', async () => {
prowlarrMock.getIndexers.mockResolvedValueOnce([{ id: 1, name: 'Indexer', protocol: 'torrent' }]);
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', priority: 5, seedingTimeMinutes: 10 }]));
configServiceMock.get.mockResolvedValueOnce('[]');
const { GET } = await import('@/app/api/admin/settings/prowlarr/indexers/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.indexers[0].enabled).toBe(true);
});
it('saves indexer configuration', async () => {
authRequest.json.mockResolvedValue({
indexers: [{ id: 1, name: 'Indexer', enabled: true, priority: 10, seedingTimeMinutes: 0 }],
flagConfigs: [],
});
const { PUT } = await import('@/app/api/admin/settings/prowlarr/indexers/route');
const response = await PUT({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(configServiceMock.setMany).toHaveBeenCalled();
});
});
@@ -0,0 +1,257 @@
/**
* 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 testFlareSolverrMock = vi.hoisted(() => vi.fn());
const fsMock = vi.hoisted(() => ({
access: vi.fn(),
constants: { R_OK: 4 },
}));
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/services/ebook-scraper', () => ({
testFlareSolverrConnection: testFlareSolverrMock,
}));
vi.mock('fs/promises', () => ({
default: fsMock,
...fsMock,
}));
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('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('tests download client connection', async () => {
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.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.version).toBe('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 () => {
prismaMock.configuration.findUnique.mockResolvedValueOnce({ value: 'stored-pass' });
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.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);
expect(qbtMock.testConnectionWithCredentials).toHaveBeenCalledWith(
'http://qbt',
'user',
'stored-pass',
false
);
});
it('returns error when masked password is missing in storage', async () => {
prismaMock.configuration.findUnique.mockResolvedValueOnce(null);
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 () => {
sabnzbdMock.testConnection.mockResolvedValueOnce({ success: false, error: '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(500);
expect(payload.error).toMatch(/bad key/);
});
it('requires path mapping values when enabled', async () => {
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0');
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 () => {
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0');
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' }) };
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);
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* Component: Admin Users 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());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
describe('Admin users routes', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { sub: 'admin-1', role: 'admin' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
});
it('returns users list', async () => {
prismaMock.user.findMany.mockResolvedValueOnce([{ id: 'u1' }]);
const { GET } = await import('@/app/api/admin/users/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.users).toHaveLength(1);
});
it('returns pending users list', async () => {
prismaMock.user.findMany.mockResolvedValueOnce([{ id: 'u2' }]);
const { GET } = await import('@/app/api/admin/users/pending/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.users).toHaveLength(1);
});
it('updates a user role', async () => {
prismaMock.user.findUnique.mockResolvedValueOnce({
isSetupAdmin: false,
authProvider: 'local',
plexUsername: 'user',
deletedAt: null,
});
prismaMock.user.update.mockResolvedValueOnce({ id: 'u3', plexUsername: 'user', role: 'admin' });
const request = { json: vi.fn().mockResolvedValue({ role: 'admin' }) };
const { PUT } = await import('@/app/api/admin/users/[id]/route');
const response = await PUT(request as any, { params: Promise.resolve({ id: 'u3' }) });
const payload = await response.json();
expect(payload.user.role).toBe('admin');
});
it('soft deletes a local user', async () => {
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'u4',
plexUsername: 'user',
isSetupAdmin: false,
authProvider: 'local',
deletedAt: null,
_count: { requests: 1 },
});
prismaMock.user.update.mockResolvedValueOnce({});
const { DELETE } = await import('@/app/api/admin/users/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'u4' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
});
it('approves a pending user', async () => {
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'u5',
plexUsername: 'user',
registrationStatus: 'pending_approval',
});
prismaMock.user.update.mockResolvedValueOnce({});
const request = { json: vi.fn().mockResolvedValue({ approve: true }) };
const { POST } = await import('@/app/api/admin/users/[id]/approve/route');
const response = await POST(request as any, { params: Promise.resolve({ id: 'u5' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Component: Audiobooks Browse API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
const audibleServiceMock = vi.hoisted(() => ({
search: vi.fn(),
getAudiobookDetails: vi.fn(),
}));
const enrichMock = vi.hoisted(() => vi.fn());
const currentUserMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => audibleServiceMock,
}));
vi.mock('@/lib/utils/audiobook-matcher', () => ({
enrichAudiobooksWithMatches: enrichMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
getCurrentUser: currentUserMock,
}));
describe('Audiobooks browse routes', () => {
beforeEach(() => {
vi.clearAllMocks();
enrichMock.mockResolvedValue([]);
currentUserMock.mockReturnValue(null);
});
it('searches Audible and enriches results', async () => {
audibleServiceMock.search.mockResolvedValue({
query: 'query',
results: [{ asin: 'ASIN', title: 'Title', author: 'Author' }],
totalResults: 1,
page: 1,
hasMore: false,
});
currentUserMock.mockReturnValue({ sub: 'user-1' });
enrichMock.mockResolvedValue([{ asin: 'ASIN', available: false }]);
const { GET } = await import('@/app/api/audiobooks/search/route');
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/search?q=query') } as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(enrichMock).toHaveBeenCalledWith([{ asin: 'ASIN', title: 'Title', author: 'Author' }], 'user-1');
});
it('returns 400 for invalid popular pagination', async () => {
const { GET } = await import('@/app/api/audiobooks/popular/route');
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/popular?page=0') } as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('returns popular audiobooks with cached cover URLs', async () => {
prismaMock.audibleCache.findMany.mockResolvedValueOnce([
{
asin: 'ASIN',
title: 'Title',
author: 'Author',
narrator: null,
description: null,
coverArtUrl: 'http://image',
cachedCoverPath: '/tmp/cache/asin.jpg',
durationMinutes: 90,
releaseDate: new Date('2024-01-01'),
rating: 4.5,
genres: [],
lastSyncedAt: new Date(),
},
]);
prismaMock.audibleCache.count.mockResolvedValueOnce(1);
enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', coverArtUrl: '/api/cache/thumbnails/asin.jpg' }]);
const { GET } = await import('@/app/api/audiobooks/popular/route');
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/popular?page=1&limit=1') } as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.audiobooks[0].coverArtUrl).toBe('/api/cache/thumbnails/asin.jpg');
});
it('returns new release audiobooks', async () => {
prismaMock.audibleCache.findMany.mockResolvedValueOnce([]);
prismaMock.audibleCache.count.mockResolvedValueOnce(0);
const { GET } = await import('@/app/api/audiobooks/new-releases/route');
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.count).toBe(0);
});
it('returns audiobook details when ASIN is valid', async () => {
audibleServiceMock.getAudiobookDetails.mockResolvedValue({ asin: 'ASIN123456', title: 'Title' });
const { GET } = await import('@/app/api/audiobooks/[asin]/route');
const response = await GET({} as any, { params: Promise.resolve({ asin: 'ASIN123456' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.audiobook.asin).toBe('ASIN123456');
});
it('returns cached covers for login', async () => {
prismaMock.audibleCache.findMany.mockResolvedValueOnce([
{ asin: 'ASIN', title: 'Title', author: 'Author', cachedCoverPath: '/tmp/asin.jpg', coverArtUrl: null },
]);
const { GET } = await import('@/app/api/audiobooks/covers/route');
const response = await GET();
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.covers[0].coverUrl).toBe('/api/cache/thumbnails/asin.jpg');
});
});
@@ -0,0 +1,94 @@
/**
* Component: Request With Torrent API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const prismaMock = createPrismaMock();
const jobQueueMock = vi.hoisted(() => ({
addDownloadJob: vi.fn(),
}));
const findPlexMatchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: findPlexMatchMock,
}));
describe('Request with torrent route', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = {
user: { id: 'user-1', role: 'user' },
json: vi.fn(),
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('returns 409 when audiobook is already being processed', async () => {
authRequest.json.mockResolvedValue({
audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' },
torrent: { guid: 'guid', title: 'Torrent', size: 100, indexer: 'Indexer', downloadUrl: 'url', publishDate: '2024-01-01' },
});
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-1',
status: 'downloaded',
userId: 'user-2',
user: { plexUsername: 'other' },
});
const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(409);
expect(payload.error).toBe('BeingProcessed');
});
it('creates request and queues download job', async () => {
authRequest.json.mockResolvedValue({
audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' },
torrent: { guid: 'guid', title: 'Torrent', size: 100, indexer: 'Indexer', downloadUrl: 'url', publishDate: '2024-01-01' },
});
prismaMock.request.findFirst.mockResolvedValueOnce(null);
findPlexMatchMock.mockResolvedValueOnce(null);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' });
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.request.create.mockResolvedValueOnce({
id: 'req-2',
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
user: { id: 'user-1', plexUsername: 'user' },
});
const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(201);
expect(payload.success).toBe(true);
expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith('req-2', {
id: 'ab-1',
title: 'Title',
author: 'Author',
}, expect.objectContaining({ guid: 'guid' }));
});
});
@@ -0,0 +1,96 @@
/**
* Component: Audiobooks Search Torrents API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const configServiceMock = vi.hoisted(() => ({
get: vi.fn(),
}));
const prowlarrMock = vi.hoisted(() => ({
search: vi.fn(),
}));
const rankTorrentsMock = vi.hoisted(() => vi.fn());
const groupIndexersMock = vi.hoisted(() => vi.fn());
const groupDescriptionMock = vi.hoisted(() => vi.fn(() => 'Group'));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
vi.mock('@/lib/integrations/prowlarr.service', () => ({
getProwlarrService: async () => prowlarrMock,
}));
vi.mock('@/lib/utils/ranking-algorithm', () => ({
rankTorrents: rankTorrentsMock,
}));
vi.mock('@/lib/utils/indexer-grouping', () => ({
groupIndexersByCategories: groupIndexersMock,
getGroupDescription: groupDescriptionMock,
}));
describe('Audiobooks search torrents route', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = {
user: { id: 'user-1', role: 'user' },
json: vi.fn(),
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('returns error when no indexers are configured', async () => {
authRequest.json.mockResolvedValue({ title: 'Title', author: 'Author' });
configServiceMock.get.mockResolvedValueOnce(null);
const { POST } = await import('@/app/api/audiobooks/search-torrents/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ConfigError');
});
it('returns ranked results with rank order', async () => {
authRequest.json.mockResolvedValue({ title: 'Title', author: 'Author' });
configServiceMock.get
.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', priority: 10 }]))
.mockResolvedValueOnce(null);
groupIndexersMock.mockReturnValue([{ categories: [1], indexerIds: [1] }]);
prowlarrMock.search.mockResolvedValue([{ title: 'Result', size: 100, indexer: 'Indexer', indexerId: 1 }]);
rankTorrentsMock.mockReturnValue([
{
title: 'Result',
size: 100,
indexer: 'Indexer',
indexerId: 1,
score: 50,
breakdown: { matchScore: 50, formatScore: 0, sizeScore: 0, seederScore: 0, notes: [] },
bonusPoints: 0,
bonusModifiers: [],
finalScore: 50,
},
]);
const { POST } = await import('@/app/api/audiobooks/search-torrents/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.results[0].rank).toBe(1);
expect(rankTorrentsMock).toHaveBeenCalled();
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* Component: Admin Login API Route Tests
* Documentation: documentation/backend/services/auth.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
const bcryptMock = {
compare: vi.fn(),
};
const encryptionMock = {
decrypt: vi.fn(),
};
const tokenMock = {
generateAccessToken: vi.fn(() => 'access-token'),
generateRefreshToken: vi.fn(() => 'refresh-token'),
};
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('bcrypt', () => ({
default: bcryptMock,
...bcryptMock,
}));
vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock,
}));
vi.mock('@/lib/utils/jwt', () => ({
generateAccessToken: tokenMock.generateAccessToken,
generateRefreshToken: tokenMock.generateRefreshToken,
}));
const makeRequest = (body: Record<string, any>) => ({
json: vi.fn().mockResolvedValue(body),
});
describe('Admin login route', () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.DISABLE_LOCAL_LOGIN;
});
it('blocks local login when disabled', async () => {
process.env.DISABLE_LOCAL_LOGIN = 'true';
const { POST } = await import('@/app/api/auth/admin/login/route');
const response = await POST(makeRequest({ username: 'admin', password: 'pass' }) as any);
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Local login is disabled');
});
it('rejects missing credentials', async () => {
const { POST } = await import('@/app/api/auth/admin/login/route');
const response = await POST(makeRequest({ username: 'admin' }) as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('rejects unknown user', async () => {
prismaMock.user.findUnique.mockResolvedValue(null);
const { POST } = await import('@/app/api/auth/admin/login/route');
const response = await POST(makeRequest({ username: 'admin', password: 'pass' }) as any);
const payload = await response.json();
expect(response.status).toBe(401);
expect(payload.error).toBe('AuthenticationError');
});
it('rejects invalid password', async () => {
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
plexId: 'local-admin',
plexUsername: 'admin',
plexEmail: null,
role: 'admin',
avatarUrl: null,
authToken: 'enc-hash',
});
encryptionMock.decrypt.mockReturnValue('hash');
bcryptMock.compare.mockResolvedValue(false);
const { POST } = await import('@/app/api/auth/admin/login/route');
const response = await POST(makeRequest({ username: 'admin', password: 'wrong' }) as any);
const payload = await response.json();
expect(response.status).toBe(401);
expect(payload.error).toBe('AuthenticationError');
});
it('rejects when password verification throws', async () => {
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-2',
plexId: 'local-admin',
plexUsername: 'admin',
plexEmail: null,
role: 'admin',
avatarUrl: null,
authToken: 'enc-hash',
});
encryptionMock.decrypt.mockImplementation(() => {
throw new Error('decrypt failed');
});
const { POST } = await import('@/app/api/auth/admin/login/route');
const response = await POST(makeRequest({ username: 'admin', password: 'pass' }) as any);
const payload = await response.json();
expect(response.status).toBe(401);
expect(payload.error).toBe('AuthenticationError');
});
it('returns tokens for valid credentials', async () => {
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-3',
plexId: 'local-admin',
plexUsername: 'admin',
plexEmail: 'admin@example.com',
role: 'admin',
avatarUrl: null,
authToken: 'enc-hash',
});
prismaMock.user.update.mockResolvedValue({});
encryptionMock.decrypt.mockReturnValue('hash');
bcryptMock.compare.mockResolvedValue(true);
const { POST } = await import('@/app/api/auth/admin/login/route');
const response = await POST(makeRequest({ username: 'admin', password: 'pass' }) as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(payload.accessToken).toBe('access-token');
expect(payload.refreshToken).toBe('refresh-token');
expect(prismaMock.user.update).toHaveBeenCalled();
});
});
@@ -0,0 +1,161 @@
/**
* Component: Change Password API Route Tests
* Documentation: documentation/backend/services/auth.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
const bcryptMock = {
compare: vi.fn(),
hash: vi.fn(),
};
const encryptionMock = {
decrypt: vi.fn(),
encrypt: vi.fn(),
};
const requireAuthMock = vi.fn();
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('bcrypt', () => ({
default: bcryptMock,
...bcryptMock,
}));
vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
const makeRequest = (body: Record<string, any>) => ({
json: vi.fn().mockResolvedValue(body),
});
describe('Change password route', () => {
beforeEach(() => {
vi.clearAllMocks();
requireAuthMock.mockImplementation((_req: any, handler: any) =>
handler({ user: { id: 'user-1' } })
);
});
it('validates required fields', async () => {
const { POST } = await import('@/app/api/auth/change-password/route');
const response = await POST(makeRequest({ currentPassword: 'old' }) as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/required/i);
});
it('rejects short passwords', async () => {
const { POST } = await import('@/app/api/auth/change-password/route');
const response = await POST(
makeRequest({ currentPassword: 'old', newPassword: 'short', confirmPassword: 'short' }) as any
);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/at least 8 characters/i);
});
it('blocks non-local users', async () => {
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
authProvider: 'plex',
authToken: 'enc-hash',
plexId: 'plex-1',
plexUsername: 'user',
});
const { POST } = await import('@/app/api/auth/change-password/route');
const response = await POST(
makeRequest({ currentPassword: 'oldpass', newPassword: 'newpass123', confirmPassword: 'newpass123' }) as any
);
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toMatch(/local users/i);
});
it('rejects incorrect current password', async () => {
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
authProvider: 'local',
authToken: 'enc-hash',
plexId: 'local-user',
plexUsername: 'user',
});
encryptionMock.decrypt.mockReturnValue('hash');
bcryptMock.compare.mockResolvedValue(false);
const { POST } = await import('@/app/api/auth/change-password/route');
const response = await POST(
makeRequest({ currentPassword: 'wrong', newPassword: 'newpass123', confirmPassword: 'newpass123' }) as any
);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/incorrect/i);
});
it('returns error when decryption fails', async () => {
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
authProvider: 'local',
authToken: 'enc-hash',
plexId: 'local-user',
plexUsername: 'user',
});
encryptionMock.decrypt.mockImplementation(() => {
throw new Error('decrypt failed');
});
const { POST } = await import('@/app/api/auth/change-password/route');
const response = await POST(
makeRequest({ currentPassword: 'oldpass', newPassword: 'newpass123', confirmPassword: 'newpass123' }) as any
);
const payload = await response.json();
expect(response.status).toBe(500);
expect(payload.error).toMatch(/verify current password/i);
});
it('updates password for local user', async () => {
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
authProvider: 'local',
authToken: 'enc-hash',
plexId: 'local-user',
plexUsername: 'user',
});
encryptionMock.decrypt.mockReturnValue('hash');
bcryptMock.compare.mockResolvedValue(true);
bcryptMock.hash.mockResolvedValue('new-hash');
encryptionMock.encrypt.mockReturnValue('enc-new-hash');
prismaMock.user.update.mockResolvedValue({});
const { POST } = await import('@/app/api/auth/change-password/route');
const response = await POST(
makeRequest({ currentPassword: 'oldpass', newPassword: 'newpass123', confirmPassword: 'newpass123' }) as any
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(prismaMock.user.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ authToken: 'enc-new-hash' }),
})
);
});
});
@@ -0,0 +1,47 @@
/**
* Component: Is Local Admin API Route Tests
* Documentation: documentation/backend/services/auth.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const requireAuthMock = vi.fn();
const isLocalAdminMock = vi.fn();
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
isLocalAdmin: isLocalAdminMock,
}));
describe('Is local admin route', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns false when request has no user', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) => handler({}));
const { GET } = await import('@/app/api/auth/is-local-admin/route');
const response = await GET({} as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.isLocalAdmin).toBe(false);
expect(isLocalAdminMock).not.toHaveBeenCalled();
});
it('returns local admin status for user', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) =>
handler({ user: { id: 'user-1' } })
);
isLocalAdminMock.mockResolvedValue(true);
const { GET } = await import('@/app/api/auth/is-local-admin/route');
const response = await GET({} as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.isLocalAdmin).toBe(true);
expect(isLocalAdminMock).toHaveBeenCalledWith('user-1');
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* Component: Local Auth API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const localAuthProviderMock = {
handleCallback: vi.fn(),
register: vi.fn(),
};
vi.mock('@/lib/services/auth/LocalAuthProvider', () => ({
LocalAuthProvider: class {
handleCallback = localAuthProviderMock.handleCallback;
register = localAuthProviderMock.register;
},
}));
const makeRequest = (body: any, headers?: Record<string, string>) => ({
json: vi.fn().mockResolvedValue(body),
headers: {
get: (key: string) => headers?.[key.toLowerCase()] || null,
},
});
describe('Local auth routes', () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.DISABLE_LOCAL_LOGIN;
});
it('rejects login when local auth is disabled', async () => {
process.env.DISABLE_LOCAL_LOGIN = 'true';
const { POST } = await import('@/app/api/auth/local/login/route');
const response = await POST(makeRequest({ username: 'user', password: 'pass' }) as any);
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Local login is disabled');
});
it('rejects login when username or password missing', async () => {
const { POST } = await import('@/app/api/auth/local/login/route');
const response = await POST(makeRequest({ username: 'user' }) as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('Username and password are required');
});
it('logs in successfully with local credentials', async () => {
localAuthProviderMock.handleCallback.mockResolvedValue({
success: true,
user: { id: 'u1' },
tokens: { accessToken: 'access', refreshToken: 'refresh' },
});
const { POST } = await import('@/app/api/auth/local/login/route');
const response = await POST(makeRequest({ username: 'user', password: 'pass' }) as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(payload.accessToken).toBe('access');
});
it('returns pending approval for local login', async () => {
localAuthProviderMock.handleCallback.mockResolvedValue({
success: false,
requiresApproval: true,
});
const { POST } = await import('@/app/api/auth/local/login/route');
const response = await POST(makeRequest({ username: 'user', password: 'pass' }) as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.pendingApproval).toBe(true);
});
it('registers a local user and returns tokens', async () => {
localAuthProviderMock.register.mockResolvedValue({
success: true,
user: { id: 'u2' },
tokens: { accessToken: 'access', refreshToken: 'refresh' },
});
const { POST } = await import('@/app/api/auth/register/route');
const request = makeRequest({ username: 'user', password: 'pass' }, { 'x-forwarded-for': 'ip-1' });
const response = await POST(request as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(payload.refreshToken).toBe('refresh');
});
it('rate limits repeated registration attempts by IP', async () => {
localAuthProviderMock.register.mockResolvedValue({
success: true,
user: { id: 'u3' },
tokens: { accessToken: 'access', refreshToken: 'refresh' },
});
const { POST } = await import('@/app/api/auth/register/route');
const request = makeRequest({ username: 'user', password: 'pass' }, { 'x-forwarded-for': 'ip-2' });
for (let i = 0; i < 5; i += 1) {
const response = await POST(request as any);
expect(response.status).toBe(200);
}
const blocked = await POST(request as any);
const payload = await blocked.json();
expect(blocked.status).toBe(429);
expect(payload.error).toMatch(/Too many registration attempts/);
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* Component: Auth Misc 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 verifyRefreshTokenMock = vi.hoisted(() => vi.fn());
const generateAccessTokenMock = vi.hoisted(() => vi.fn());
const configServiceMock = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
vi.mock('@/lib/utils/jwt', () => ({
verifyRefreshToken: verifyRefreshTokenMock,
generateAccessToken: generateAccessTokenMock,
}));
vi.mock('@/lib/services/config.service', () => ({
ConfigurationService: class {
get = configServiceMock.get;
},
}));
describe('Auth misc routes', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = {
user: { id: 'user-1', role: 'user' },
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
delete process.env.DISABLE_LOCAL_LOGIN;
});
it('logs out successfully', async () => {
const { POST } = await import('@/app/api/auth/logout/route');
const response = await POST();
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
});
it('returns current user details with local admin flag', async () => {
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
plexId: 'local-admin',
plexUsername: 'admin',
plexEmail: 'admin@example.com',
role: 'admin',
isSetupAdmin: true,
avatarUrl: null,
authProvider: 'local',
createdAt: new Date(),
lastLoginAt: new Date(),
});
const { GET } = await import('@/app/api/auth/me/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.user.isLocalAdmin).toBe(true);
expect(payload.user.username).toBe('admin');
});
it('refreshes access token when refresh token is valid', async () => {
verifyRefreshTokenMock.mockReturnValue({ sub: 'user-1' });
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
plexId: 'plex-1',
plexUsername: 'user',
role: 'user',
});
generateAccessTokenMock.mockReturnValue('access-token');
const { POST } = await import('@/app/api/auth/refresh/route');
const response = await POST({ json: vi.fn().mockResolvedValue({ refreshToken: 'refresh' }) } as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.accessToken).toBe('access-token');
});
it('returns provider info for audiobookshelf mode', async () => {
configServiceMock.get
.mockResolvedValueOnce('audiobookshelf')
.mockResolvedValueOnce('prowlarr')
.mockResolvedValueOnce('http://prowlarr')
.mockResolvedValueOnce('true')
.mockResolvedValueOnce('true')
.mockResolvedValueOnce('MyOIDC');
prismaMock.user.count.mockResolvedValueOnce(1);
const { GET } = await import('@/app/api/auth/providers/route');
const response = await GET();
const payload = await response.json();
expect(payload.backendMode).toBe('audiobookshelf');
expect(payload.providers).toContain('oidc');
expect(payload.registrationEnabled).toBe(true);
expect(payload.oidcProviderName).toBe('MyOIDC');
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* Component: OIDC Auth API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const authProviderMock = vi.hoisted(() => ({
initiateLogin: vi.fn(),
handleCallback: vi.fn(),
}));
const getBaseUrlMock = vi.hoisted(() => vi.fn(() => 'http://app'));
vi.mock('@/lib/services/auth', () => ({
getAuthProvider: async () => authProviderMock,
}));
vi.mock('@/lib/utils/url', () => ({
getBaseUrl: getBaseUrlMock,
}));
const makeRequest = (url: string) => ({
nextUrl: new URL(url),
});
describe('OIDC auth routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('redirects to provider on login', async () => {
authProviderMock.initiateLogin.mockResolvedValue({ redirectUrl: 'http://oidc/login' });
const { GET } = await import('@/app/api/auth/oidc/login/route');
const response = await GET();
expect(response.status).toBe(307);
expect(response.headers.get('location')).toBe('http://oidc/login');
});
it('redirects to login on missing code/state', async () => {
const { GET } = await import('@/app/api/auth/oidc/callback/route');
const response = await GET(makeRequest('http://app/api/auth/oidc/callback') as any);
expect(response.status).toBe(307);
expect(response.headers.get('location')).toContain('/login?error=');
});
it('redirects with pending approval when required', async () => {
authProviderMock.handleCallback.mockResolvedValue({ success: false, requiresApproval: true });
const { GET } = await import('@/app/api/auth/oidc/callback/route');
const response = await GET(makeRequest('http://app/api/auth/oidc/callback?code=abc&state=def') as any);
expect(response.status).toBe(307);
expect(response.headers.get('location')).toBe('http://app/login?pending=approval');
});
it('returns HTML response for successful callback', async () => {
authProviderMock.handleCallback.mockResolvedValue({
success: true,
tokens: { accessToken: 'access', refreshToken: 'refresh' },
user: { id: 'u1', username: 'user', email: 'a@b.com', avatarUrl: null, isAdmin: false },
isFirstLogin: false,
});
const { GET } = await import('@/app/api/auth/oidc/callback/route');
const response = await GET(makeRequest('http://app/api/auth/oidc/callback?code=abc&state=def') as any);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/html');
});
});
+309
View File
@@ -0,0 +1,309 @@
/**
* Component: Plex Auth API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
const plexServiceMock = vi.hoisted(() => ({
requestPin: vi.fn(),
getOAuthUrl: vi.fn(),
checkPin: vi.fn(),
getUserInfo: vi.fn(),
verifyServerAccess: vi.fn(),
getHomeUsers: vi.fn(),
switchHomeUser: vi.fn(),
}));
const encryptionServiceMock = vi.hoisted(() => ({
encrypt: vi.fn((value: string) => `enc-${value}`),
}));
const configServiceMock = vi.hoisted(() => ({
getPlexConfig: vi.fn(),
}));
const generateAccessTokenMock = vi.hoisted(() => vi.fn(() => 'access-token'));
const generateRefreshTokenMock = vi.hoisted(() => vi.fn(() => 'refresh-token'));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/integrations/plex.service', () => ({
getPlexService: () => plexServiceMock,
}));
vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionServiceMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
vi.mock('@/lib/utils/jwt', () => ({
generateAccessToken: generateAccessTokenMock,
generateRefreshToken: generateRefreshTokenMock,
}));
const makeRequest = (url: string, headers?: Record<string, string>) => ({
nextUrl: new URL(url),
headers: {
get: (key: string) => headers?.[key.toLowerCase()] || null,
},
json: vi.fn(),
});
describe('Plex auth routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('initiates Plex login and returns auth URL', async () => {
plexServiceMock.requestPin.mockResolvedValue({ id: 1, code: 'code-1' });
plexServiceMock.getOAuthUrl.mockReturnValue('http://plex/auth');
const { POST } = await import('@/app/api/auth/plex/login/route');
const response = await POST(makeRequest('http://localhost/api/auth/plex/login', { origin: 'http://app' }) as any);
const payload = await response.json();
expect(payload.authUrl).toBe('http://plex/auth');
expect(plexServiceMock.getOAuthUrl).toHaveBeenCalledWith('code-1', 1, 'http://app/api/auth/plex/callback');
});
it('returns 400 when pinId is missing', async () => {
const { GET } = await import('@/app/api/auth/plex/callback/route');
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback') as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('returns 202 when waiting for authorization', async () => {
plexServiceMock.checkPin.mockResolvedValue(null);
const { GET } = await import('@/app/api/auth/plex/callback/route');
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=1') as any);
const payload = await response.json();
expect(response.status).toBe(202);
expect(payload.authorized).toBe(false);
});
it('denies access when Plex server is not configured', async () => {
plexServiceMock.checkPin.mockResolvedValue('token');
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: null, authToken: null });
const { GET } = await import('@/app/api/auth/plex/callback/route');
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any);
const payload = await response.json();
expect(response.status).toBe(503);
expect(payload.error).toBe('ConfigurationError');
});
it('denies access when machine identifier is missing', async () => {
plexServiceMock.checkPin.mockResolvedValue('token');
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
configServiceMock.getPlexConfig.mockResolvedValue({
serverUrl: 'http://plex',
authToken: 'token',
machineIdentifier: null,
});
const { GET } = await import('@/app/api/auth/plex/callback/route');
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any);
const payload = await response.json();
expect(response.status).toBe(503);
expect(payload.error).toBe('ConfigurationError');
});
it('rejects when user lacks server access', async () => {
plexServiceMock.checkPin.mockResolvedValue('token');
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
configServiceMock.getPlexConfig.mockResolvedValue({
serverUrl: 'http://plex',
authToken: 'token',
machineIdentifier: 'machine',
});
plexServiceMock.verifyServerAccess.mockResolvedValue(false);
const { GET } = await import('@/app/api/auth/plex/callback/route');
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any);
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('AccessDenied');
});
it('returns errors when Plex user info is incomplete', async () => {
plexServiceMock.checkPin.mockResolvedValue('token');
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: '' });
const { GET } = await import('@/app/api/auth/plex/callback/route');
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any);
const payload = await response.json();
expect(response.status).toBe(500);
expect(payload.error).toBe('OAuthError');
expect(payload.details).toContain('Username is missing');
});
it('returns profile selection info when multiple home users exist', async () => {
plexServiceMock.checkPin.mockResolvedValue('token');
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
configServiceMock.getPlexConfig.mockResolvedValue({
serverUrl: 'http://plex',
authToken: 'token',
machineIdentifier: 'machine',
});
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }, { id: 2 }]);
const { GET } = await import('@/app/api/auth/plex/callback/route');
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=3', { accept: 'application/json' }) as any);
const payload = await response.json();
expect(payload.requiresProfileSelection).toBe(true);
expect(payload.homeUsers).toBe(2);
});
it('returns HTML redirect for browser profile selection', async () => {
plexServiceMock.checkPin.mockResolvedValue('token');
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
configServiceMock.getPlexConfig.mockResolvedValue({
serverUrl: 'http://plex',
authToken: 'token',
machineIdentifier: 'machine',
});
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }, { id: 2 }]);
const { GET } = await import('@/app/api/auth/plex/callback/route');
const response = await GET(
makeRequest('http://localhost/api/auth/plex/callback?pinId=3', {
accept: 'text/html',
host: 'example.com',
'x-forwarded-proto': 'https',
}) as any
);
const html = await response.text();
expect(response.headers.get('content-type')).toContain('text/html');
expect(html).toContain('sessionStorage.setItem');
expect(html).toContain('https://example.com/auth/select-profile?pinId=3');
});
it('returns tokens for successful Plex auth', async () => {
plexServiceMock.checkPin.mockResolvedValue('token');
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
configServiceMock.getPlexConfig.mockResolvedValue({
serverUrl: 'http://plex',
authToken: 'token',
machineIdentifier: 'machine',
});
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
plexServiceMock.getHomeUsers.mockResolvedValue([]);
prismaMock.user.count.mockResolvedValue(0);
prismaMock.user.upsert.mockResolvedValue({
id: 'user-1',
plexId: 'plex-1',
plexUsername: 'user',
plexEmail: null,
role: 'admin',
avatarUrl: null,
});
const { GET } = await import('@/app/api/auth/plex/callback/route');
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=4', { accept: 'application/json' }) as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.accessToken).toBe('access-token');
});
it('returns HTML redirect with cookies for browser auth', async () => {
plexServiceMock.checkPin.mockResolvedValue('token');
plexServiceMock.getUserInfo.mockResolvedValue({
id: 'plex-1',
username: 'user',
email: 'user@example.com',
thumb: '/t',
});
configServiceMock.getPlexConfig.mockResolvedValue({
serverUrl: 'http://plex',
authToken: 'token',
machineIdentifier: 'machine',
});
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
plexServiceMock.getHomeUsers.mockResolvedValue([]);
prismaMock.user.count.mockResolvedValue(1);
prismaMock.user.upsert.mockResolvedValue({
id: 'user-1',
plexId: 'plex-1',
plexUsername: 'user',
plexEmail: 'user@example.com',
role: 'user',
avatarUrl: '/t',
});
const { GET } = await import('@/app/api/auth/plex/callback/route');
const response = await GET(
makeRequest('http://localhost/api/auth/plex/callback?pinId=4', {
accept: 'text/html',
host: 'example.com',
'x-forwarded-proto': 'https',
}) as any
);
const html = await response.text();
expect(response.headers.get('content-type')).toContain('text/html');
expect(response.cookies.get('accessToken')?.value).toBe('access-token');
expect(response.cookies.get('refreshToken')?.value).toBe('refresh-token');
expect(html).toContain('#authData=');
});
it('returns Plex home users when token is provided', async () => {
plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }]);
const { GET } = await import('@/app/api/auth/plex/home-users/route');
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-token': 'token' }) as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.users).toHaveLength(1);
});
it('switches Plex profile using provided profile info', async () => {
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
prismaMock.user.count.mockResolvedValue(1);
prismaMock.user.upsert.mockResolvedValue({
id: 'user-2',
plexId: 'uuid-1',
plexUsername: 'Profile',
plexEmail: null,
role: 'user',
avatarUrl: null,
});
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
request.json.mockResolvedValue({
userId: 'home-1',
pin: '1234',
profileInfo: { uuid: 'uuid-1', friendlyName: 'Profile' },
});
const response = await POST(request as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.accessToken).toBe('access-token');
});
});
@@ -0,0 +1,446 @@
/**
* Component: BookDate Test Connection Route Tests
* Documentation: documentation/features/bookdate-prd.md
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
const requireAuthMock = vi.hoisted(() => vi.fn());
const encryptionMock = vi.hoisted(() => ({
decrypt: vi.fn(),
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock,
}));
describe('BookDate test connection route', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('rejects unauthenticated use of saved keys', async () => {
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => null },
json: vi.fn().mockResolvedValue({ provider: 'openai', useSavedKey: true }),
} as any);
const payload = await response.json();
expect(response.status).toBe(401);
expect(payload.error).toMatch(/Authentication required/i);
});
it('requires API key for OpenAI unauthenticated requests', async () => {
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => null },
json: vi.fn().mockResolvedValue({ provider: 'openai' }),
} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/API key is required/);
});
it('requires baseUrl for custom unauthenticated requests', async () => {
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => null },
json: vi.fn().mockResolvedValue({ provider: 'custom' }),
} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/Base URL is required/);
});
it('requires provider for unauthenticated requests', async () => {
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => null },
json: vi.fn().mockResolvedValue({}),
} as any);
expect(response.status).toBe(400);
});
it('validates provider for unauthenticated requests', async () => {
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => null },
json: vi.fn().mockResolvedValue({ provider: 'invalid', apiKey: 'x' }),
} as any);
expect(response.status).toBe(400);
});
it('returns filtered OpenAI models for unauthenticated requests', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
data: [
{ id: 'gpt-4-1' },
{ id: 'gpt-3.5-turbo' },
{ id: 'gpt-4-0' },
],
}),
text: vi.fn().mockResolvedValue('ok'),
});
vi.stubGlobal('fetch', fetchMock);
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => null },
json: vi.fn().mockResolvedValue({ provider: 'openai', apiKey: 'key' }),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.models.map((m: any) => m.id)).toEqual(['gpt-4-0', 'gpt-4-1']);
});
it('returns filtered OpenAI models for authenticated requests', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
data: [
{ id: 'gpt-4-2' },
{ id: 'gpt-3.5-turbo' },
{ id: 'gpt-4-1' },
],
}),
text: vi.fn().mockResolvedValue('ok'),
});
vi.stubGlobal('fetch', fetchMock);
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => 'Bearer token' },
json: vi.fn().mockResolvedValue({ provider: 'openai', apiKey: 'key' }),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.models.map((m: any) => m.id)).toEqual(['gpt-4-1', 'gpt-4-2']);
});
it('returns Claude models for unauthenticated requests', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: vi.fn().mockResolvedValue('ok'),
});
vi.stubGlobal('fetch', fetchMock);
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => null },
json: vi.fn().mockResolvedValue({ provider: 'claude', apiKey: 'key' }),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.models.length).toBe(4);
});
it('returns OpenAI error for unauthenticated requests with invalid key', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
text: vi.fn().mockResolvedValue('bad key'),
});
vi.stubGlobal('fetch', fetchMock);
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => null },
json: vi.fn().mockResolvedValue({ provider: 'openai', apiKey: 'bad' }),
} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/Invalid OpenAI API key/i);
});
it('returns error when saved config is missing', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
prismaMock.bookDateConfig.findFirst.mockResolvedValue(null);
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => 'Bearer token' },
json: vi.fn().mockResolvedValue({ provider: 'openai', useSavedKey: true }),
} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/No saved configuration/i);
});
it('returns error when saved key decryption fails', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
prismaMock.bookDateConfig.findFirst.mockResolvedValue({ apiKey: 'enc-key', baseUrl: null });
encryptionMock.decrypt.mockImplementation(() => {
throw new Error('decrypt failed');
});
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => 'Bearer token' },
json: vi.fn().mockResolvedValue({ provider: 'openai', useSavedKey: true }),
} as any);
const payload = await response.json();
expect(response.status).toBe(500);
expect(payload.error).toMatch(/Failed to decrypt/i);
});
it('requires API key for authenticated OpenAI requests', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => 'Bearer token' },
json: vi.fn().mockResolvedValue({ provider: 'openai', apiKey: '' }),
} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/API key is required/);
});
it('requires baseUrl when using saved custom config without baseUrl', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
prismaMock.bookDateConfig.findFirst.mockResolvedValue({
apiKey: 'enc-key',
baseUrl: null,
});
encryptionMock.decrypt.mockReturnValue('decrypted');
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => 'Bearer token' },
json: vi.fn().mockResolvedValue({ provider: 'custom', useSavedKey: true }),
} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/No saved base URL/i);
});
it('uses saved key for custom provider and parses OpenAI format', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
prismaMock.bookDateConfig.findFirst.mockResolvedValue({
apiKey: 'enc-key',
baseUrl: 'http://custom',
});
encryptionMock.decrypt.mockReturnValue('decrypted');
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
data: [{ id: 'model-a', name: 'Model A' }],
}),
text: vi.fn().mockResolvedValue('ok'),
});
vi.stubGlobal('fetch', fetchMock);
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => 'Bearer token' },
json: vi.fn().mockResolvedValue({ provider: 'custom', useSavedKey: true }),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.models).toEqual([{ id: 'model-a', name: 'Model A' }]);
});
it('validates custom base URLs for authenticated requests', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => 'Bearer token' },
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'ftp://bad' }),
} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/Invalid base URL/i);
});
it('validates custom base URLs for unauthenticated requests', async () => {
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => null },
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'ftp://bad' }),
} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/Invalid base URL/i);
});
it('returns custom provider models for authenticated requests', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'model-a' },
{ id: 'model-b', name: 'Model B' },
]),
text: vi.fn().mockResolvedValue('ok'),
});
vi.stubGlobal('fetch', fetchMock);
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => 'Bearer token' },
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: '' }),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.models).toEqual([
{ id: 'model-a', name: 'model-a' },
{ id: 'model-b', name: 'Model B' },
]);
});
it('returns helpful message when custom models list cannot be parsed', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ unexpected: true }),
text: vi.fn().mockResolvedValue('ok'),
});
vi.stubGlobal('fetch', fetchMock);
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => 'Bearer token' },
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: 'key' }),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.message).toMatch(/could not parse models list/i);
});
it('returns network error when custom provider fetch fails', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
const fetchMock = vi.fn().mockRejectedValue(new Error('network down'));
vi.stubGlobal('fetch', fetchMock);
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => 'Bearer token' },
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: 'key' }),
} as any);
const payload = await response.json();
expect(response.status).toBe(500);
expect(payload.error).toMatch(/Network error/i);
});
it('returns 400 (not 401) when custom provider returns 401 to prevent logout', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: vi.fn().mockResolvedValue('Unauthorized'),
});
vi.stubGlobal('fetch', fetchMock);
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => 'Bearer token' },
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: 'bad-key' }),
} as any);
const payload = await response.json();
// Should return 400, not 401, to prevent fetchWithAuth from logging user out
expect(response.status).toBe(400);
expect(payload.error).toMatch(/Failed to connect to custom provider/i);
});
it('returns 400 (not 401) when custom provider returns 401 during unauthenticated request', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: vi.fn().mockResolvedValue('Unauthorized'),
});
vi.stubGlobal('fetch', fetchMock);
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => null },
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: 'bad-key' }),
} as any);
const payload = await response.json();
// Should return 400, not 401, to prevent fetchWithAuth from logging user out
expect(response.status).toBe(400);
expect(payload.error).toMatch(/Failed to connect to custom provider/i);
});
it('allows custom provider when saved key decryption fails', async () => {
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
prismaMock.bookDateConfig.findFirst.mockResolvedValue({
apiKey: 'enc-key',
baseUrl: 'http://custom',
});
encryptionMock.decrypt.mockImplementation(() => {
throw new Error('decrypt failed');
});
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([{ id: 'model-a' }]),
text: vi.fn().mockResolvedValue('ok'),
});
vi.stubGlobal('fetch', fetchMock);
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => 'Bearer token' },
json: vi.fn().mockResolvedValue({ provider: 'custom', useSavedKey: true }),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
'http://custom/models',
expect.objectContaining({
method: 'GET',
headers: {},
})
);
});
});
+501
View File
@@ -0,0 +1,501 @@
/**
* Component: BookDate 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 encryptionMock = vi.hoisted(() => ({
encrypt: vi.fn((value: string) => `enc-${value}`),
decrypt: vi.fn((value: string) => value.replace('enc-', '')),
}));
const configServiceMock = vi.hoisted(() => ({
getBackendMode: vi.fn(),
}));
const jobQueueMock = vi.hoisted(() => ({
addSearchJob: vi.fn(),
}));
const bookdateHelpersMock = vi.hoisted(() => ({
buildAIPrompt: vi.fn(),
callAI: vi.fn(),
matchToAudnexus: vi.fn(),
isInLibrary: vi.fn(),
isAlreadyRequested: vi.fn(),
isAlreadySwiped: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/bookdate/helpers', () => bookdateHelpersMock);
describe('BookDate routes', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'user-1', role: 'admin' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('returns BookDate config without API key', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
id: 'cfg-1',
apiKey: 'secret',
provider: 'openai',
model: 'gpt',
});
const { GET } = await import('@/app/api/bookdate/config/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.config.apiKey).toBeUndefined();
expect(payload.config.provider).toBe('openai');
});
it('returns null config when not configured', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
const { GET } = await import('@/app/api/bookdate/config/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.config).toBeNull();
});
it('saves BookDate config and clears recommendations', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
prismaMock.bookDateConfig.create.mockResolvedValueOnce({
id: 'cfg-2',
provider: 'openai',
model: 'gpt',
apiKey: 'enc-secret',
});
prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 });
authRequest.json.mockResolvedValue({ provider: 'openai', apiKey: 'secret', model: 'gpt' });
const { POST } = await import('@/app/api/bookdate/config/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.bookDateRecommendation.deleteMany).toHaveBeenCalled();
});
it('rejects missing required fields when saving config', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
authRequest.json.mockResolvedValue({ provider: 'openai', apiKey: 'key' });
const { POST } = await import('@/app/api/bookdate/config/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/Missing required fields/);
});
it('rejects invalid provider when saving config', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
authRequest.json.mockResolvedValue({ provider: 'invalid', apiKey: 'key', model: 'gpt' });
const { POST } = await import('@/app/api/bookdate/config/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/Invalid provider/);
});
it('rejects custom provider without baseUrl', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
authRequest.json.mockResolvedValue({ provider: 'custom', apiKey: '', model: 'model-x' });
const { POST } = await import('@/app/api/bookdate/config/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/Base URL is required/);
});
it('rejects custom provider with invalid baseUrl', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
authRequest.json.mockResolvedValue({
provider: 'custom',
apiKey: '',
model: 'model-x',
baseUrl: 'ftp://bad',
});
const { POST } = await import('@/app/api/bookdate/config/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/Invalid base URL/);
});
it('updates existing config without a new API key', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
id: 'cfg-9',
apiKey: 'enc-existing',
});
prismaMock.bookDateConfig.update.mockResolvedValueOnce({
id: 'cfg-9',
provider: 'openai',
model: 'gpt-4',
apiKey: 'enc-existing',
});
authRequest.json.mockResolvedValue({ provider: 'openai', model: 'gpt-4' });
const { POST } = await import('@/app/api/bookdate/config/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.bookDateConfig.update).toHaveBeenCalled();
});
it('creates custom config with empty API key', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
prismaMock.bookDateConfig.create.mockResolvedValueOnce({
id: 'cfg-10',
provider: 'custom',
model: 'model-x',
apiKey: 'enc-',
});
prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 });
authRequest.json.mockResolvedValue({
provider: 'custom',
model: 'model-x',
baseUrl: 'http://custom',
});
const { POST } = await import('@/app/api/bookdate/config/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(encryptionMock.encrypt).toHaveBeenCalledWith('');
});
it('deletes BookDate config', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ id: 'cfg-3' });
prismaMock.bookDateConfig.delete.mockResolvedValueOnce({});
prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 });
prismaMock.bookDateSwipe.deleteMany.mockResolvedValueOnce({ count: 1 });
const { DELETE } = await import('@/app/api/bookdate/config/route');
const response = await DELETE({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
});
it('returns 404 when deleting missing BookDate config', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
const { DELETE } = await import('@/app/api/bookdate/config/route');
const response = await DELETE({} as any);
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toMatch(/Configuration not found/);
});
it('returns BookDate preferences', async () => {
prismaMock.user.findUnique.mockResolvedValueOnce({
bookDateLibraryScope: 'full',
bookDateCustomPrompt: null,
bookDateOnboardingComplete: true,
});
configServiceMock.getBackendMode.mockResolvedValueOnce('plex');
const { GET } = await import('@/app/api/bookdate/preferences/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.libraryScope).toBe('full');
expect(payload.onboardingComplete).toBe(true);
});
it('updates BookDate preferences', async () => {
configServiceMock.getBackendMode.mockResolvedValueOnce('plex');
prismaMock.user.update.mockResolvedValueOnce({
bookDateLibraryScope: 'rated',
bookDateCustomPrompt: 'Prompt',
bookDateOnboardingComplete: true,
});
authRequest.json.mockResolvedValue({ libraryScope: 'rated', customPrompt: 'Prompt', onboardingComplete: true });
const { PUT } = await import('@/app/api/bookdate/preferences/route');
const response = await PUT({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.libraryScope).toBe('rated');
});
it('returns cached recommendations without calling AI', async () => {
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([{ id: 'rec-1' }]);
const { GET } = await import('@/app/api/bookdate/recommendations/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.source).toBe('cache');
expect(payload.recommendations).toHaveLength(1);
});
it('returns error when recommendations are disabled', async () => {
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]);
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
isVerified: true,
isEnabled: false,
});
const { GET } = await import('@/app/api/bookdate/recommendations/route');
const response = await GET({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/not configured/i);
});
it('returns 404 when recommendation user is missing', async () => {
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]);
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
isVerified: true,
isEnabled: true,
provider: 'openai',
model: 'gpt',
apiKey: 'enc-key',
});
prismaMock.user.findUnique.mockResolvedValueOnce(null);
const { GET } = await import('@/app/api/bookdate/recommendations/route');
const response = await GET({} as any);
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toMatch(/User not found/i);
});
it('generates and stores recommendations when AI returns matches', async () => {
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]);
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
isVerified: true,
isEnabled: true,
provider: 'openai',
model: 'gpt',
apiKey: 'enc-key',
baseUrl: null,
});
prismaMock.user.findUnique.mockResolvedValueOnce({
bookDateLibraryScope: 'full',
bookDateCustomPrompt: null,
});
bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}');
bookdateHelpersMock.callAI.mockResolvedValueOnce({
recommendations: [{ title: 'Title', author: 'Author', reason: 'Because' }],
});
bookdateHelpersMock.isAlreadySwiped.mockResolvedValue(false);
bookdateHelpersMock.isInLibrary.mockResolvedValue(false);
bookdateHelpersMock.matchToAudnexus.mockResolvedValueOnce({
asin: 'ASIN1',
title: 'Title',
author: 'Author',
narrator: null,
rating: null,
description: null,
coverUrl: null,
});
bookdateHelpersMock.isAlreadyRequested.mockResolvedValue(false);
(prismaMock.bookDateRecommendation as any).createMany = vi.fn().mockResolvedValueOnce({ count: 1 });
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([{ id: 'rec-1' }]);
const { GET } = await import('@/app/api/bookdate/recommendations/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.source).toBe('generated');
expect(prismaMock.bookDateRecommendation.createMany).toHaveBeenCalled();
expect(payload.recommendations).toHaveLength(1);
});
it('returns error when generating recommendations without config', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
const { POST } = await import('@/app/api/bookdate/generate/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/not configured/);
});
it('returns 404 when no new recommendations can be matched', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
isVerified: true,
isEnabled: true,
provider: 'openai',
model: 'gpt',
apiKey: 'enc-key',
baseUrl: null,
});
prismaMock.user.findUnique.mockResolvedValueOnce({
bookDateLibraryScope: 'full',
bookDateCustomPrompt: null,
});
bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}');
bookdateHelpersMock.callAI.mockResolvedValueOnce({
recommendations: [{ title: 'Title', author: 'Author' }],
});
bookdateHelpersMock.isAlreadySwiped.mockResolvedValue(false);
bookdateHelpersMock.isInLibrary.mockResolvedValue(false);
bookdateHelpersMock.matchToAudnexus.mockResolvedValueOnce(null);
const { POST } = await import('@/app/api/bookdate/generate/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toMatch(/Could not find any new recommendations/i);
});
it('stores generated recommendations from the AI', async () => {
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
isVerified: true,
isEnabled: true,
provider: 'openai',
model: 'gpt',
apiKey: 'enc-key',
baseUrl: null,
});
prismaMock.user.findUnique.mockResolvedValueOnce({
bookDateLibraryScope: 'full',
bookDateCustomPrompt: null,
});
bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}');
bookdateHelpersMock.callAI.mockResolvedValueOnce({
recommendations: [{ title: 'Title', author: 'Author', reason: 'Because' }],
});
bookdateHelpersMock.isAlreadySwiped.mockResolvedValue(false);
bookdateHelpersMock.isInLibrary.mockResolvedValue(false);
bookdateHelpersMock.matchToAudnexus.mockResolvedValueOnce({
asin: 'ASIN1',
title: 'Title',
author: 'Author',
narrator: null,
rating: null,
description: null,
coverUrl: null,
});
bookdateHelpersMock.isAlreadyRequested.mockResolvedValue(false);
(prismaMock.bookDateRecommendation as any).createMany = vi.fn().mockResolvedValueOnce({ count: 1 });
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([{ id: 'rec-2' }]);
const { POST } = await import('@/app/api/bookdate/generate/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.source).toBe('generated');
expect(prismaMock.bookDateRecommendation.createMany).toHaveBeenCalled();
expect(payload.recommendations).toHaveLength(1);
});
it('records swipe and creates request on right swipe', async () => {
authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'right', markedAsKnown: false });
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
id: 'rec-1',
userId: 'user-1',
title: 'Title',
author: 'Author',
audnexusAsin: 'ASIN',
});
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({});
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' });
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-1' });
const { POST } = await import('@/app/api/bookdate/swipe/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
});
it('undoes last swipe', async () => {
prismaMock.bookDateSwipe.findFirst.mockResolvedValueOnce({
id: 'swipe-1',
recommendation: { id: 'rec-1', createdAt: new Date() },
});
prismaMock.bookDateRecommendation.findFirst.mockResolvedValueOnce(null);
prismaMock.bookDateSwipe.delete.mockResolvedValueOnce({});
prismaMock.bookDateRecommendation.update.mockResolvedValueOnce({ id: 'rec-1' });
const { POST } = await import('@/app/api/bookdate/undo/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
});
it('clears all swipes as admin', async () => {
prismaMock.bookDateSwipe.deleteMany.mockResolvedValueOnce({ count: 1 });
prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 });
const { DELETE } = await import('@/app/api/bookdate/swipes/route');
const response = await DELETE({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
});
it('tests BookDate connection without auth', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: [{ id: 'model-1' }] }),
text: vi.fn().mockResolvedValue('ok'),
});
vi.stubGlobal('fetch', fetchMock);
const { POST } = await import('@/app/api/bookdate/test-connection/route');
const response = await POST({
headers: { get: () => null },
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: '' }),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.models[0].id).toBe('model-1');
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* Component: Cache API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fsMock = vi.hoisted(() => ({
access: vi.fn(),
readFile: vi.fn(),
}));
vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } }));
describe('Thumbnail cache route', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('rejects invalid filenames', async () => {
const { GET } = await import('@/app/api/cache/thumbnails/[filename]/route');
const response = await GET({} as any, { params: Promise.resolve({ filename: '../bad' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('Invalid filename');
});
it('returns 404 when file is missing', async () => {
fsMock.access.mockRejectedValueOnce(new Error('missing'));
const { GET } = await import('@/app/api/cache/thumbnails/[filename]/route');
const response = await GET({} as any, { params: Promise.resolve({ filename: 'file.jpg' }) });
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('File not found');
});
it('serves cached image with content type', async () => {
fsMock.access.mockResolvedValueOnce(undefined);
fsMock.readFile.mockResolvedValueOnce(Buffer.from('data'));
const { GET } = await import('@/app/api/cache/thumbnails/[filename]/route');
const response = await GET({} as any, { params: Promise.resolve({ filename: 'file.png' }) });
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('image/png');
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* Component: Config API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const configServiceMock = vi.hoisted(() => ({
setMany: vi.fn(),
getAll: vi.fn(),
getCategory: vi.fn(),
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
describe('Config API routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns full configuration', async () => {
configServiceMock.getAll.mockResolvedValue({ plex_url: 'http://plex' });
const { GET } = await import('@/app/api/config/route');
const response = await GET();
const payload = await response.json();
expect(payload.config.plex_url).toBe('http://plex');
});
it('updates configuration values', async () => {
const { PUT } = await import('@/app/api/config/route');
const response = await PUT({
json: vi.fn().mockResolvedValue({
updates: [{ key: 'plex_url', value: 'http://plex' }],
}),
} as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.updated).toBe(1);
expect(configServiceMock.setMany).toHaveBeenCalled();
});
it('returns category configuration', async () => {
configServiceMock.getCategory.mockResolvedValue({ plex_url: 'http://plex' });
const { GET } = await import('@/app/api/config/[category]/route');
const response = await GET({} as any, { params: Promise.resolve({ category: 'plex' }) });
const payload = await response.json();
expect(payload.category).toBe('plex');
expect(payload.config.plex_url).toBe('http://plex');
});
});
+244
View File
@@ -0,0 +1,244 @@
/**
* Component: Request Action 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 prowlarrMock = vi.hoisted(() => ({ search: vi.fn() }));
const rankTorrentsMock = vi.hoisted(() => vi.fn());
const configServiceMock = vi.hoisted(() => ({ get: vi.fn() }));
const configState = vi.hoisted(() => ({
values: new Map<string, string>(),
}));
const jobQueueMock = vi.hoisted(() => ({
addSearchJob: vi.fn(),
addDownloadJob: vi.fn(),
}));
const downloadEbookMock = vi.hoisted(() => vi.fn());
const fsMock = vi.hoisted(() => ({
access: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: vi.fn((req: any, handler: any) => handler()),
}));
vi.mock('@/lib/integrations/prowlarr.service', () => ({
getProwlarrService: async () => prowlarrMock,
}));
vi.mock('@/lib/utils/ranking-algorithm', () => ({
rankTorrents: rankTorrentsMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/services/ebook-scraper', () => ({
downloadEbook: downloadEbookMock,
}));
vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } }));
describe('Request action routes', () => {
beforeEach(() => {
vi.clearAllMocks();
configState.values.clear();
authRequest = { user: { id: 'user-1', role: 'user' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
prismaMock.configuration.findUnique.mockImplementation(
async ({ where: { key } }: { where: { key: string } }) => {
const value = configState.values.get(key);
return value !== undefined ? { value } : null;
}
);
});
it('performs interactive search and ranks results', async () => {
authRequest.json.mockResolvedValue({});
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-1',
userId: 'user-1',
audiobook: { title: 'Title', author: 'Author' },
});
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10 }]));
configServiceMock.get.mockResolvedValueOnce(null);
prowlarrMock.search.mockResolvedValueOnce([{ title: 'Result', size: 100 }]);
rankTorrentsMock.mockReturnValueOnce([
{ title: 'Result', score: 50, breakdown: { matchScore: 50, formatScore: 0, seederScore: 0, notes: [] }, bonusPoints: 0, bonusModifiers: [], finalScore: 50 },
]);
const { POST } = await import('@/app/api/requests/[id]/interactive-search/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.results[0].rank).toBe(1);
});
it('triggers manual search job', async () => {
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-2',
userId: 'user-1',
status: 'failed',
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
});
prismaMock.request.update.mockResolvedValueOnce({ id: 'req-2', status: 'pending' });
const { POST } = await import('@/app/api/requests/[id]/manual-search/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-2' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
});
it('selects a torrent and queues download', async () => {
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-3',
userId: 'user-1',
audiobook: { id: 'ab-2', title: 'Title', author: 'Author' },
});
prismaMock.request.update.mockResolvedValueOnce({ id: 'req-3', status: 'downloading' });
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-3' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(jobQueueMock.addDownloadJob).toHaveBeenCalled();
});
it('returns error when ebook sidecar is disabled', async () => {
configState.values.set('ebook_sidecar_enabled', 'false');
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-4' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/not enabled/);
});
it('returns 404 when request is not found', async () => {
configState.values.set('ebook_sidecar_enabled', 'true');
prismaMock.request.findUnique.mockResolvedValueOnce(null);
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-missing' }) });
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toMatch(/not found/);
});
it('returns 400 when request status is not eligible for ebook fetch', async () => {
configState.values.set('ebook_sidecar_enabled', 'true');
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-5',
status: 'pending',
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
});
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-5' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/Cannot fetch e-book/);
});
it('returns 400 when audiobook directory is missing', async () => {
configState.values.set('ebook_sidecar_enabled', 'true');
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-6',
status: 'downloaded',
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
});
fsMock.access.mockRejectedValueOnce(new Error('missing'));
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-6' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/directory not found/);
});
it('downloads ebook and returns success', async () => {
configState.values.set('ebook_sidecar_enabled', 'true');
configState.values.set('media_dir', '/media/audiobooks');
configState.values.set('ebook_sidecar_preferred_format', 'epub');
configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example');
configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr');
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-7',
status: 'available',
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
});
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({ releaseDate: '2022-05-01' });
fsMock.access.mockResolvedValueOnce(undefined);
downloadEbookMock.mockResolvedValueOnce({
success: true,
format: 'epub',
filePath: '/media/audiobooks/Author/Title (2022) ASIN123/Title.epub',
});
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-7' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(downloadEbookMock).toHaveBeenCalledWith(
'ASIN123',
'Title',
'Author',
expect.stringContaining('Title (2022) ASIN123'),
'epub',
'https://ebooks.example',
undefined,
'http://flaresolverr'
);
});
it('returns failure payload when ebook download fails', async () => {
configState.values.set('ebook_sidecar_enabled', 'true');
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-8',
status: 'downloaded',
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
});
fsMock.access.mockResolvedValueOnce(undefined);
downloadEbookMock.mockResolvedValueOnce({
success: false,
error: 'Download failed',
});
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-8' }) });
const payload = await response.json();
expect(payload.success).toBe(false);
expect(payload.message).toMatch(/Download failed/);
});
});
+322
View File
@@ -0,0 +1,322 @@
/**
* Component: Request By ID 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 jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob: vi.fn() }));
const requireAuthMock = vi.hoisted(() => vi.fn());
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
getQBittorrentService: async () => qbtMock,
}));
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
getSABnzbdService: async () => sabnzbdMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
describe('Request by ID API routes', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = {
user: { id: 'user-1', role: 'user' },
nextUrl: new URL('http://localhost/api/requests/req-1'),
json: vi.fn(),
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('returns 403 when user is not authorized to view the request', async () => {
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-1',
userId: 'user-2',
});
const { GET } = await import('@/app/api/requests/[id]/route');
const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('returns request details for the owner', async () => {
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-1',
userId: 'user-1',
audiobook: { id: 'ab-1' },
});
const { GET } = await import('@/app/api/requests/[id]/route');
const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(payload.request.id).toBe('req-1');
});
it('returns 404 when request does not exist', async () => {
prismaMock.request.findFirst.mockResolvedValueOnce(null);
const { GET } = await import('@/app/api/requests/[id]/route');
const response = await GET({} as any, { params: Promise.resolve({ id: 'missing' }) });
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('NotFound');
});
it('returns 401 when user is missing', async () => {
authRequest.user = null;
const { GET } = await import('@/app/api/requests/[id]/route');
const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(response.status).toBe(401);
expect(payload.error).toBe('Unauthorized');
});
it('cancels a request', async () => {
authRequest.json.mockResolvedValue({ action: 'cancel' });
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-2',
userId: 'user-1',
status: 'pending',
});
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-2',
status: 'cancelled',
audiobook: { id: 'ab-1' },
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-2' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.request.status).toBe('cancelled');
});
it('returns 400 for invalid actions', async () => {
authRequest.json.mockResolvedValue({ action: 'unknown' });
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-2',
userId: 'user-1',
status: 'pending',
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-2' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('rejects retry when status is not retryable', async () => {
authRequest.json.mockResolvedValue({ action: 'retry' });
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-4',
userId: 'user-1',
status: 'available',
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-4' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('retries a failed request by enqueuing a search job', async () => {
authRequest.json.mockResolvedValue({ action: 'retry' });
prismaMock.request.findFirst
.mockResolvedValueOnce({
id: 'req-3',
userId: 'user-1',
status: 'failed',
})
.mockResolvedValueOnce({
id: 'req-3',
userId: 'user-1',
audiobook: {
id: 'ab-2',
title: 'Title',
author: 'Author',
audibleAsin: 'ASIN-2',
},
});
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-3',
status: 'pending',
audiobook: { id: 'ab-2' },
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-3' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-3', {
id: 'ab-2',
title: 'Title',
author: 'Author',
asin: 'ASIN-2',
});
});
it('retries an import via qBittorrent download history', async () => {
authRequest.json.mockResolvedValue({ action: 'retry' });
prismaMock.request.findFirst
.mockResolvedValueOnce({
id: 'req-5',
userId: 'user-1',
status: 'warn',
})
.mockResolvedValueOnce({
id: 'req-5',
userId: 'user-1',
audiobook: { id: 'ab-5' },
downloadHistory: [{ torrentHash: 'hash-1', selected: true }],
});
qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' });
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-5',
status: 'processing',
audiobook: { id: 'ab-5' },
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-5' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith('req-5', 'ab-5', '/downloads/Book');
});
it('retries an import via SABnzbd download history', async () => {
authRequest.json.mockResolvedValue({ action: 'retry' });
prismaMock.request.findFirst
.mockResolvedValueOnce({
id: 'req-6',
userId: 'user-1',
status: 'awaiting_import',
})
.mockResolvedValueOnce({
id: 'req-6',
userId: 'user-1',
audiobook: { id: 'ab-6' },
downloadHistory: [{ nzbId: 'nzb-1', selected: true }],
});
sabnzbdMock.getNZB.mockResolvedValue({ downloadPath: '/usenet/book' });
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-6',
status: 'processing',
audiobook: { id: 'ab-6' },
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-6' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith('req-6', 'ab-6', '/usenet/book');
});
it('returns 400 when download history is missing for import retry', async () => {
authRequest.json.mockResolvedValue({ action: 'retry' });
prismaMock.request.findFirst
.mockResolvedValueOnce({
id: 'req-7',
userId: 'user-1',
status: 'warn',
})
.mockResolvedValueOnce({
id: 'req-7',
userId: 'user-1',
audiobook: { id: 'ab-7' },
downloadHistory: [],
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-7' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('returns 400 when download client info is missing for import retry', async () => {
authRequest.json.mockResolvedValue({ action: 'retry' });
prismaMock.request.findFirst
.mockResolvedValueOnce({
id: 'req-8',
userId: 'user-1',
status: 'warn',
})
.mockResolvedValueOnce({
id: 'req-8',
userId: 'user-1',
audiobook: { id: 'ab-8' },
downloadHistory: [{}],
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-8' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('allows admins to delete requests', async () => {
authRequest.user = { id: 'admin-1', role: 'admin' };
prismaMock.request.delete.mockResolvedValueOnce({});
const { DELETE } = await import('@/app/api/requests/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-4' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-4' } });
});
it('blocks delete for non-admin users', async () => {
authRequest.user = { id: 'user-2', role: 'user' };
const { DELETE } = await import('@/app/api/requests/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-9' }) });
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
});
+163
View File
@@ -0,0 +1,163 @@
/**
* Component: Requests 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 jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn() }));
const findPlexMatchMock = vi.hoisted(() => vi.fn());
const requireAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: findPlexMatchMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
describe('Requests API routes', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = {
user: { id: 'user-1', role: 'user' },
nextUrl: new URL('http://localhost/api/requests'),
json: vi.fn(),
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('returns 409 when an active request already exists', async () => {
authRequest.json.mockResolvedValue({
audiobook: { asin: 'ASIN-1', title: 'Title', author: 'Author' },
});
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-1',
status: 'downloaded',
userId: 'user-2',
user: { plexUsername: 'someone' },
});
const { POST } = await import('@/app/api/requests/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(409);
expect(payload.error).toBe('BeingProcessed');
expect(findPlexMatchMock).not.toHaveBeenCalled();
});
it('returns 409 when a Plex match already exists', async () => {
authRequest.json.mockResolvedValue({
audiobook: { asin: 'ASIN-2', title: 'Title', author: 'Author' },
});
prismaMock.request.findFirst.mockResolvedValueOnce(null);
findPlexMatchMock.mockResolvedValueOnce({ plexGuid: 'plex-1' });
const { POST } = await import('@/app/api/requests/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(409);
expect(payload.error).toBe('AlreadyAvailable');
});
it('creates a new request and enqueues a search job', async () => {
authRequest.json.mockResolvedValue({
audiobook: { asin: 'ASIN-3', title: 'Title', author: 'Author' },
});
prismaMock.request.findFirst.mockResolvedValueOnce(null);
findPlexMatchMock.mockResolvedValueOnce(null);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({
id: 'ab-1',
title: 'Title',
author: 'Author',
audibleAsin: 'ASIN-3',
});
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.request.create.mockResolvedValueOnce({
id: 'req-2',
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN-3' },
user: { id: 'user-1', plexUsername: 'user' },
});
const { POST } = await import('@/app/api/requests/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(201);
expect(payload.success).toBe(true);
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-2', {
id: 'ab-1',
title: 'Title',
author: 'Author',
asin: 'ASIN-3',
});
});
it('skips auto-search when skipAutoSearch=true', async () => {
authRequest.nextUrl = new URL('http://localhost/api/requests?skipAutoSearch=true');
authRequest.json.mockResolvedValue({
audiobook: { asin: 'ASIN-4', title: 'Title', author: 'Author' },
});
prismaMock.request.findFirst.mockResolvedValueOnce(null);
findPlexMatchMock.mockResolvedValueOnce(null);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({
id: 'ab-2',
title: 'Title',
author: 'Author',
audibleAsin: 'ASIN-4',
});
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.request.create.mockResolvedValueOnce({
id: 'req-3',
audiobook: { id: 'ab-2', title: 'Title', author: 'Author', audibleAsin: 'ASIN-4' },
user: { id: 'user-1', plexUsername: 'user' },
});
const { POST } = await import('@/app/api/requests/route');
await POST({} as any);
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
expect(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'awaiting_search' }),
})
);
});
it('filters requests for current user when not admin', async () => {
authRequest.nextUrl = new URL('http://localhost/api/requests?status=pending&limit=5');
prismaMock.request.findMany.mockResolvedValueOnce([]);
const { GET } = await import('@/app/api/requests/route');
const response = await GET({} as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(prismaMock.request.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ userId: 'user-1', status: 'pending' }),
take: 5,
})
);
});
});
+203
View File
@@ -0,0 +1,203 @@
/**
* Component: Setup Validation API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const plexServiceMock = vi.hoisted(() => ({
testConnection: vi.fn(),
getLibraries: vi.fn(),
}));
const qbtMock = vi.hoisted(() => ({
testConnectionWithCredentials: vi.fn(),
}));
const sabnzbdMock = vi.hoisted(() => ({
testConnection: vi.fn(),
}));
const prowlarrMock = vi.hoisted(() => ({
getIndexers: vi.fn(),
}));
const issuerMock = vi.hoisted(() => ({
discover: vi.fn(),
}));
const fsMock = vi.hoisted(() => ({
access: vi.fn(),
mkdir: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
}));
const configServiceMock = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock('@/lib/integrations/plex.service', () => ({
getPlexService: () => plexServiceMock,
}));
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/prowlarr.service', () => ({
ProwlarrService: class {
constructor() {}
getIndexers = prowlarrMock.getIndexers;
},
}));
vi.mock('openid-client', () => ({
Issuer: issuerMock,
}));
vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } }));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
describe('Setup test routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('validates Plex connection and returns libraries', async () => {
plexServiceMock.testConnection.mockResolvedValue({
success: true,
info: { platform: 'Plex', version: '1.0', machineIdentifier: 'machine' },
});
plexServiceMock.getLibraries.mockResolvedValue([{ id: '1', title: 'Books', type: 'book' }]);
const { POST } = await import('@/app/api/setup/test-plex/route');
const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://plex', token: 'token' }) } as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.libraries[0].id).toBe('1');
});
it('tests qBittorrent credentials', async () => {
qbtMock.testConnectionWithCredentials.mockResolvedValue('4.0.0');
const { POST } = await import('@/app/api/setup/test-download-client/route');
const response = await POST({
json: vi.fn().mockResolvedValue({
type: 'qbittorrent',
url: 'http://qbt',
username: 'user',
password: 'pass',
}),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.version).toBe('4.0.0');
});
it('tests SABnzbd connection', async () => {
sabnzbdMock.testConnection.mockResolvedValue({ success: true, version: '3.0' });
const { POST } = await import('@/app/api/setup/test-download-client/route');
const response = await POST({
json: vi.fn().mockResolvedValue({
type: 'sabnzbd',
url: 'http://sab',
password: 'api-key',
}),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.version).toBe('3.0');
});
it('tests Prowlarr indexers', async () => {
prowlarrMock.getIndexers.mockResolvedValue([
{ id: 1, name: 'Indexer', protocol: 'torrent', enable: true, capabilities: {} },
{ id: 2, name: 'Disabled', protocol: 'torrent', enable: false, capabilities: {} },
]);
const { POST } = await import('@/app/api/setup/test-prowlarr/route');
const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) } as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.indexerCount).toBe(1);
});
it('validates OIDC issuer discovery', async () => {
issuerMock.discover.mockResolvedValue({
issuer: 'http://issuer',
metadata: {
authorization_endpoint: 'http://issuer/auth',
token_endpoint: 'http://issuer/token',
userinfo_endpoint: 'http://issuer/user',
jwks_uri: 'http://issuer/jwks',
scopes_supported: ['openid'],
response_types_supported: ['code'],
},
});
const { POST } = await import('@/app/api/setup/test-oidc/route');
const response = await POST({
json: vi.fn().mockResolvedValue({
issuerUrl: 'http://issuer',
clientId: 'client',
clientSecret: 'secret',
}),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.issuer.authorizationEndpoint).toBe('http://issuer/auth');
});
it('validates paths are writable', async () => {
fsMock.access.mockRejectedValueOnce(new Error('missing'));
fsMock.mkdir.mockResolvedValueOnce(undefined);
fsMock.writeFile.mockResolvedValueOnce(undefined);
fsMock.unlink.mockResolvedValueOnce(undefined);
fsMock.access.mockResolvedValueOnce(undefined);
fsMock.writeFile.mockResolvedValueOnce(undefined);
fsMock.unlink.mockResolvedValueOnce(undefined);
const { POST } = await import('@/app/api/setup/test-paths/route');
const response = await POST({
json: vi.fn().mockResolvedValue({ downloadDir: '/downloads', mediaDir: '/media' }),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.downloadDirValid).toBe(true);
});
it('tests Audiobookshelf connection with saved token', async () => {
configServiceMock.get.mockResolvedValueOnce('token');
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ libraries: [{ id: '1', name: 'Lib', mediaType: 'book', stats: { totalItems: 10 } }] }),
});
vi.stubGlobal('fetch', fetchMock);
const { POST } = await import('@/app/api/setup/test-abs/route');
const response = await POST({
json: vi.fn().mockResolvedValue({ serverUrl: 'http://abs', apiToken: '********' }),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.libraries[0].id).toBe('1');
});
});
+196
View File
@@ -0,0 +1,196 @@
/**
* Component: Setup API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
const bcryptMock = vi.hoisted(() => ({
hash: vi.fn(),
}));
const encryptionMock = vi.hoisted(() => ({
encrypt: vi.fn((value: string) => `enc-${value}`),
}));
const generateAccessTokenMock = vi.hoisted(() => vi.fn(() => 'access-token'));
const generateRefreshTokenMock = vi.hoisted(() => vi.fn(() => 'refresh-token'));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('bcrypt', () => ({
default: bcryptMock,
...bcryptMock,
}));
vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock,
}));
vi.mock('@/lib/utils/jwt', () => ({
generateAccessToken: generateAccessTokenMock,
generateRefreshToken: generateRefreshTokenMock,
}));
describe('Setup routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns setup status from configuration', async () => {
prismaMock.configuration.findUnique.mockResolvedValueOnce({ value: 'true' });
const { GET } = await import('@/app/api/setup/status/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.setupComplete).toBe(true);
});
it('rejects invalid backend mode on setup completion', async () => {
const { POST } = await import('@/app/api/setup/complete/route');
const response = await POST({ json: vi.fn().mockResolvedValue({ backendMode: 'invalid' }) } as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/Invalid or missing backend mode/);
});
it('completes setup for Plex mode and returns tokens', async () => {
bcryptMock.hash.mockResolvedValue('hashed');
prismaMock.user.create.mockResolvedValue({
id: 'admin-1',
plexId: 'local-admin',
plexUsername: 'admin',
plexEmail: null,
role: 'admin',
avatarUrl: null,
});
prismaMock.configuration.upsert.mockResolvedValue({});
prismaMock.scheduledJob.updateMany.mockResolvedValue({ count: 1 });
prismaMock.bookDateConfig.findFirst.mockResolvedValue(null);
const { POST } = await import('@/app/api/setup/complete/route');
const response = await POST({
json: vi.fn().mockResolvedValue({
backendMode: 'plex',
admin: { username: 'admin', password: 'pass' },
plex: { url: 'http://plex', token: 'token', audiobook_library_id: 'lib', machine_identifier: 'machine' },
prowlarr: { url: 'http://prowlarr', api_key: 'key', indexers: [{ id: 1 }] },
downloadClient: { type: 'qbittorrent', url: 'http://qbt', username: 'u', password: 'p' },
paths: { download_dir: '/downloads', media_dir: '/media' },
}),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.accessToken).toBe('access-token');
expect(prismaMock.configuration.upsert).toHaveBeenCalled();
});
it('completes setup for Audiobookshelf with both auth methods', async () => {
bcryptMock.hash.mockResolvedValue('hashed');
prismaMock.user.create.mockResolvedValue({
id: 'admin-2',
plexId: 'local-admin',
plexUsername: 'admin',
plexEmail: null,
role: 'admin',
avatarUrl: null,
});
prismaMock.configuration.upsert.mockResolvedValue({});
prismaMock.scheduledJob.updateMany.mockResolvedValue({ count: 1 });
prismaMock.bookDateConfig.findFirst.mockResolvedValue({ id: 'bookdate-1' });
prismaMock.bookDateConfig.update.mockResolvedValue({});
const { POST } = await import('@/app/api/setup/complete/route');
const response = await POST({
json: vi.fn().mockResolvedValue({
backendMode: 'audiobookshelf',
admin: { username: 'admin', password: 'pass' },
authMethod: 'both',
audiobookshelf: {
server_url: 'http://abs',
api_token: 'abs-token',
library_id: 'lib',
trigger_scan_after_import: true,
},
oidc: {
provider_name: 'OIDC',
issuer_url: 'https://issuer',
client_id: 'client-id',
client_secret: 'client-secret',
access_control_method: 'open',
access_group_claim: 'groups',
access_group_value: '',
allowed_emails: '[]',
allowed_usernames: '[]',
admin_claim_enabled: 'true',
admin_claim_name: 'groups',
admin_claim_value: 'admins',
},
registration: { require_admin_approval: true },
prowlarr: { url: 'http://prowlarr', api_key: 'key', indexers: [{ id: 1 }] },
downloadClient: {
type: 'qbittorrent',
url: 'http://qbt',
username: 'u',
password: 'p',
disableSSLVerify: true,
remotePathMappingEnabled: true,
remotePath: '/remote',
localPath: '/local',
},
paths: {
download_dir: '/downloads',
media_dir: '/media',
metadata_tagging_enabled: false,
chapter_merging_enabled: true,
},
bookdate: { provider: 'openai', apiKey: 'bd-key', model: 'gpt-4' },
}),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.accessToken).toBe('access-token');
expect(prismaMock.bookDateConfig.update).toHaveBeenCalled();
});
it('completes setup for Audiobookshelf without admin user', async () => {
prismaMock.configuration.upsert.mockResolvedValue({});
prismaMock.scheduledJob.updateMany.mockResolvedValue({ count: 1 });
prismaMock.bookDateConfig.findFirst.mockResolvedValue(null);
const { POST } = await import('@/app/api/setup/complete/route');
const response = await POST({
json: vi.fn().mockResolvedValue({
backendMode: 'audiobookshelf',
authMethod: 'oidc',
audiobookshelf: {
server_url: 'http://abs',
api_token: 'abs-token',
library_id: 'lib',
},
oidc: {
provider_name: 'OIDC',
issuer_url: 'https://issuer',
client_id: 'client-id',
client_secret: 'client-secret',
},
prowlarr: { url: 'http://prowlarr', api_key: 'key', indexers: [{ id: 1 }] },
downloadClient: { type: 'qbittorrent', url: 'http://qbt', username: 'u', password: 'p' },
paths: { download_dir: '/downloads', media_dir: '/media' },
}),
} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.accessToken).toBeUndefined();
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Component: System API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
const schedulerMock = vi.hoisted(() => ({
start: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/scheduler.service', () => ({
getSchedulerService: () => schedulerMock,
}));
describe('System routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns healthy status when database is reachable', async () => {
prismaMock.$queryRaw.mockResolvedValueOnce(1);
const { GET } = await import('@/app/api/health/route');
const response = await GET();
const payload = await response.json();
expect(payload.status).toBe('healthy');
expect(payload.database).toBe('connected');
});
it('returns unhealthy status on database error', async () => {
prismaMock.$queryRaw.mockRejectedValueOnce(new Error('db down'));
const { GET } = await import('@/app/api/health/route');
const response = await GET();
const payload = await response.json();
expect(response.status).toBe(503);
expect(payload.status).toBe('unhealthy');
});
it('initializes scheduler on init endpoint', async () => {
const { GET } = await import('@/app/api/init/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(schedulerMock.start).toHaveBeenCalled();
});
it('returns version info from environment', async () => {
process.env.APP_VERSION = 'abcdef123456';
process.env.BUILD_DATE = '2025-01-01';
const { GET } = await import('@/app/api/version/route');
const response = await GET();
const payload = await response.json();
expect(payload.shortCommit).toBe('abcdef1');
expect(payload.buildDate).toBe('2025-01-01');
});
});