mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add series fields to audiobooks and update related logic
Introduces 'series' and 'seriesPart' fields to the Audiobook model and database schema. Updates API routes, file organization, and path template utilities to support series metadata. Enhances chapter merging logic, improves notification backend testing, and expands test coverage for admin and API routes.
This commit is contained in:
@@ -44,6 +44,29 @@ describe('Admin BookDate toggle route', () => {
|
||||
expect(payload.isEnabled).toBe(true);
|
||||
expect(prismaMock.bookDateConfig.updateMany).toHaveBeenCalledWith({ data: { isEnabled: true } });
|
||||
});
|
||||
|
||||
it('rejects non-boolean toggle values', async () => {
|
||||
authRequest.json.mockResolvedValue({ isEnabled: 'yes' });
|
||||
|
||||
const { PATCH } = await import('@/app/api/admin/bookdate/toggle/route');
|
||||
const response = await PATCH({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/must be a boolean/);
|
||||
});
|
||||
|
||||
it('returns 500 when toggle fails', async () => {
|
||||
authRequest.json.mockResolvedValue({ isEnabled: false });
|
||||
prismaMock.bookDateConfig.updateMany.mockRejectedValue(new Error('toggle failed'));
|
||||
|
||||
const { PATCH } = await import('@/app/api/admin/bookdate/toggle/route');
|
||||
const response = await PATCH({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/toggle failed/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ 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() }));
|
||||
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
@@ -32,7 +33,7 @@ vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
getSABnzbdService: async () => ({ getNZB: vi.fn() }),
|
||||
getSABnzbdService: async () => sabnzbdMock,
|
||||
}));
|
||||
|
||||
describe('Admin downloads route', () => {
|
||||
@@ -65,6 +66,52 @@ describe('Admin downloads route', () => {
|
||||
expect(payload.downloads[0].speed).toBe(123);
|
||||
expect(payload.downloads[0].torrentName).toBe('Torrent');
|
||||
});
|
||||
|
||||
it('returns formatted active downloads for SABnzbd', async () => {
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-2',
|
||||
status: 'downloading',
|
||||
progress: 20,
|
||||
updatedAt: new Date(),
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'NZB', downloadStatus: 'downloading' }],
|
||||
},
|
||||
]);
|
||||
configServiceMock.get.mockResolvedValueOnce('sabnzbd');
|
||||
sabnzbdMock.getNZB.mockResolvedValueOnce({ downloadSpeed: 555, timeLeft: 120 });
|
||||
|
||||
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(555);
|
||||
expect(payload.downloads[0].eta).toBe(120);
|
||||
});
|
||||
|
||||
it('returns defaults when download client lookup fails', async () => {
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-3',
|
||||
status: 'downloading',
|
||||
progress: 80,
|
||||
updatedAt: new Date(),
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading' }],
|
||||
},
|
||||
]);
|
||||
configServiceMock.get.mockResolvedValueOnce('qbittorrent');
|
||||
qbittorrentMock.getTorrent.mockRejectedValueOnce(new Error('client down'));
|
||||
|
||||
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(0);
|
||||
expect(payload.downloads[0].eta).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,41 @@ describe('Admin job status route', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.job.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('rejects non-admin tokens', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'user' });
|
||||
|
||||
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(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/Admin access required/);
|
||||
});
|
||||
|
||||
it('returns 404 when job is missing', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'admin' });
|
||||
jobQueueMock.getJob.mockResolvedValue(null);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/job-status/[id]/route');
|
||||
const response = await GET(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('Job not found');
|
||||
});
|
||||
|
||||
it('returns 500 when job lookup fails', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'admin' });
|
||||
jobQueueMock.getJob.mockRejectedValue(new Error('lookup failed'));
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toBe('InternalError');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,35 @@ describe('Admin jobs routes', () => {
|
||||
expect(payload.jobs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects job list when missing token', async () => {
|
||||
const { GET } = await import('@/app/api/admin/jobs/route');
|
||||
const response = await GET(makeRequest() as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects job list for non-admin users', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'user' });
|
||||
const { GET } = await import('@/app/api/admin/jobs/route');
|
||||
const response = await GET(makeRequest('Bearer token') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/Admin access required/);
|
||||
});
|
||||
|
||||
it('returns 500 when job list fails', async () => {
|
||||
schedulerMock.getScheduledJobs.mockRejectedValue(new Error('boom'));
|
||||
const { GET } = await import('@/app/api/admin/jobs/route');
|
||||
const response = await GET(makeRequest('Bearer token') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toBe('InternalError');
|
||||
});
|
||||
|
||||
it('creates a scheduled job', async () => {
|
||||
schedulerMock.createScheduledJob.mockResolvedValue({ id: 'job-2' });
|
||||
const { POST } = await import('@/app/api/admin/jobs/route');
|
||||
@@ -55,6 +84,35 @@ describe('Admin jobs routes', () => {
|
||||
expect(payload.job.id).toBe('job-2');
|
||||
});
|
||||
|
||||
it('rejects job creation when missing token', async () => {
|
||||
const { POST } = await import('@/app/api/admin/jobs/route');
|
||||
const response = await POST(makeRequest() as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects job creation for non-admin users', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'user' });
|
||||
const { POST } = await import('@/app/api/admin/jobs/route');
|
||||
const response = await POST(makeRequest('Bearer token') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/Admin access required/);
|
||||
});
|
||||
|
||||
it('returns 500 when job creation fails', async () => {
|
||||
schedulerMock.createScheduledJob.mockRejectedValue(new Error('create failed'));
|
||||
const { POST } = await import('@/app/api/admin/jobs/route');
|
||||
const response = await POST(makeRequest('Bearer token', { name: 'Job' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.message).toMatch(/create failed/);
|
||||
});
|
||||
|
||||
it('updates a scheduled job', async () => {
|
||||
schedulerMock.updateScheduledJob.mockResolvedValue({ id: 'job-3' });
|
||||
const { PUT } = await import('@/app/api/admin/jobs/[id]/route');
|
||||
@@ -65,6 +123,35 @@ describe('Admin jobs routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects job updates when missing token', async () => {
|
||||
const { PUT } = await import('@/app/api/admin/jobs/[id]/route');
|
||||
const response = await PUT(makeRequest() as any, { params: Promise.resolve({ id: 'job-3' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects job updates for non-admin users', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'user' });
|
||||
const { PUT } = await import('@/app/api/admin/jobs/[id]/route');
|
||||
const response = await PUT(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'job-3' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/Admin access required/);
|
||||
});
|
||||
|
||||
it('returns 500 when job update fails', async () => {
|
||||
schedulerMock.updateScheduledJob.mockRejectedValue(new Error('update failed'));
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.message).toMatch(/update failed/);
|
||||
});
|
||||
|
||||
it('deletes a scheduled job', async () => {
|
||||
schedulerMock.deleteScheduledJob.mockResolvedValue(undefined);
|
||||
const { DELETE } = await import('@/app/api/admin/jobs/[id]/route');
|
||||
@@ -75,6 +162,35 @@ describe('Admin jobs routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects job deletion when missing token', async () => {
|
||||
const { DELETE } = await import('@/app/api/admin/jobs/[id]/route');
|
||||
const response = await DELETE(makeRequest() as any, { params: Promise.resolve({ id: 'job-4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects job deletion for non-admin users', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'user' });
|
||||
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(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/Admin access required/);
|
||||
});
|
||||
|
||||
it('returns 500 when job deletion fails', async () => {
|
||||
schedulerMock.deleteScheduledJob.mockRejectedValue(new Error('delete failed'));
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.message).toMatch(/delete failed/);
|
||||
});
|
||||
|
||||
it('triggers a scheduled job', async () => {
|
||||
schedulerMock.triggerJobNow.mockResolvedValue('job-5');
|
||||
const { POST } = await import('@/app/api/admin/jobs/[id]/trigger/route');
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
prismaMock.notificationBackend = {
|
||||
findUnique: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const notificationServiceMock = vi.hoisted(() => ({
|
||||
@@ -15,6 +21,10 @@ const notificationServiceMock = vi.hoisted(() => ({
|
||||
sendToBackend: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
@@ -126,5 +136,62 @@ describe('Admin notifications test route', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
expect(notificationServiceMock.encryptConfig).toHaveBeenCalledWith('pushover', testConfig.config);
|
||||
});
|
||||
|
||||
it('tests existing backend using backend ID', async () => {
|
||||
const existingBackend = {
|
||||
id: 'backend-1',
|
||||
type: 'discord',
|
||||
name: 'Discord - Admins',
|
||||
config: { webhookUrl: 'iv:authTag:encryptedData', username: 'Bot' }, // Already encrypted
|
||||
events: ['request_available'],
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
prismaMock.notificationBackend.findUnique.mockResolvedValue(existingBackend);
|
||||
authRequest.json.mockResolvedValue({ backendId: 'backend-1' });
|
||||
notificationServiceMock.sendToBackend.mockResolvedValue(undefined);
|
||||
|
||||
const { POST } = await import('@/app/api/admin/notifications/test/route');
|
||||
const response = await POST({ json: authRequest.json } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.notificationBackend.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'backend-1' },
|
||||
});
|
||||
// Should use stored config directly, not encrypt again
|
||||
expect(notificationServiceMock.encryptConfig).not.toHaveBeenCalled();
|
||||
expect(notificationServiceMock.sendToBackend).toHaveBeenCalledWith(
|
||||
'discord',
|
||||
existingBackend.config,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 if backend ID not found', async () => {
|
||||
prismaMock.notificationBackend.findUnique.mockResolvedValue(null);
|
||||
authRequest.json.mockResolvedValue({ backendId: 'nonexistent' });
|
||||
|
||||
const { POST } = await import('@/app/api/admin/notifications/test/route');
|
||||
const response = await POST({ json: authRequest.json } as any);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const payload = await response.json();
|
||||
expect(payload.error).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('returns validation error when payload is invalid', async () => {
|
||||
authRequest.json.mockResolvedValue('bad-payload');
|
||||
|
||||
const { POST } = await import('@/app/api/admin/notifications/test/route');
|
||||
const response = await POST({ json: authRequest.json } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
expect(payload.details).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,11 @@ const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const deleteRequestMock = vi.hoisted(() => vi.fn());
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addDownloadJob: vi.fn(),
|
||||
addSearchJob: vi.fn(),
|
||||
addNotificationJob: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
@@ -26,12 +31,17 @@ vi.mock('@/lib/services/request-delete.service', () => ({
|
||||
deleteRequest: deleteRequestMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
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());
|
||||
jobQueueMock.addNotificationJob.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('returns recent requests', async () => {
|
||||
@@ -73,6 +83,202 @@ describe('Admin requests routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
expect(deleteRequestMock).toHaveBeenCalledWith('req-1', 'admin-1');
|
||||
});
|
||||
|
||||
it('returns 401 when admin user is missing', async () => {
|
||||
authRequest.user = null;
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/requests/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 404 when delete service reports missing request', async () => {
|
||||
deleteRequestMock.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'NotFound',
|
||||
message: 'Missing',
|
||||
});
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/requests/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-3' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('returns 500 when delete service fails', async () => {
|
||||
deleteRequestMock.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'DeleteFailed',
|
||||
message: 'boom',
|
||||
});
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/requests/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toBe('DeleteFailed');
|
||||
});
|
||||
|
||||
it('returns pending approval requests', async () => {
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([{ id: 'req-10', status: 'awaiting_approval' }]);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/pending-approval/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.count).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 500 when pending approval fetch fails', async () => {
|
||||
prismaMock.request.findMany.mockRejectedValueOnce(new Error('fetch failed'));
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/pending-approval/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toBe('FetchError');
|
||||
});
|
||||
|
||||
it('returns 401 when approving without a user', async () => {
|
||||
authRequest.user = null;
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns validation error for invalid approval action', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'maybe' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 404 when approving a missing request', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce(null);
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('returns 400 when request is not awaiting approval', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-2',
|
||||
status: 'pending',
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
|
||||
user: { id: 'u1', plexUsername: 'user' },
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('InvalidStatus');
|
||||
});
|
||||
|
||||
it('approves request with a selected torrent and triggers download', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-3',
|
||||
status: 'awaiting_approval',
|
||||
selectedTorrent: { title: 'Torrent' },
|
||||
audiobook: { id: 'ab-3', title: 'Title', author: 'Author' },
|
||||
user: { id: 'u3', plexUsername: 'user3' },
|
||||
userId: 'u3',
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-3',
|
||||
status: 'downloading',
|
||||
audiobook: { id: 'ab-3', title: 'Title', author: 'Author' },
|
||||
user: { id: 'u3', plexUsername: 'user3' },
|
||||
userId: 'u3',
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-3' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('approves request without a selected torrent and triggers search', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-4',
|
||||
status: 'awaiting_approval',
|
||||
selectedTorrent: null,
|
||||
audiobook: { id: 'ab-4', title: 'Title', author: 'Author', audibleAsin: 'ASIN4' },
|
||||
user: { id: 'u4', plexUsername: 'user4' },
|
||||
userId: 'u4',
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-4',
|
||||
status: 'pending',
|
||||
audiobook: { id: 'ab-4', title: 'Title', author: 'Author', audibleAsin: 'ASIN4' },
|
||||
user: { id: 'u4', plexUsername: 'user4' },
|
||||
userId: 'u4',
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies request without triggering jobs', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-5',
|
||||
status: 'awaiting_approval',
|
||||
selectedTorrent: null,
|
||||
audiobook: { id: 'ab-5', title: 'Title', author: 'Author' },
|
||||
user: { id: 'u5', plexUsername: 'user5' },
|
||||
userId: 'u5',
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-5',
|
||||
status: 'denied',
|
||||
audiobook: { id: 'ab-5', title: 'Title', author: 'Author' },
|
||||
user: { id: 'u5', plexUsername: 'user5' },
|
||||
userId: 'u5',
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'deny' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-5' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||
expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -129,6 +129,100 @@ describe('Admin settings core routes', () => {
|
||||
expect(invalidateQbMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid download client types', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'transmission',
|
||||
url: 'http://transmission',
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/download-client/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid client type/);
|
||||
});
|
||||
|
||||
it('rejects missing qBittorrent credentials', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qbt',
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/URL, username, and password/);
|
||||
});
|
||||
|
||||
it('rejects missing SABnzbd credentials', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'sabnzbd',
|
||||
url: 'http://sab',
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/API key/);
|
||||
});
|
||||
|
||||
it('rejects path mapping when required fields are missing', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qbt',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
remotePathMappingEnabled: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/download-client/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Remote path and local path/);
|
||||
});
|
||||
|
||||
it('rejects invalid path mapping configuration', async () => {
|
||||
pathMapperMock.validate.mockImplementationOnce(() => {
|
||||
throw new Error('bad mapping');
|
||||
});
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qbt',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
remotePathMappingEnabled: true,
|
||||
remotePath: '/remote',
|
||||
localPath: '/local',
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/download-client/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/bad mapping/);
|
||||
});
|
||||
|
||||
it('updates paths settings', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -24,6 +24,7 @@ const qbtMock = vi.hoisted(() => ({
|
||||
const sabnzbdMock = vi.hoisted(() => ({
|
||||
testConnection: vi.fn(),
|
||||
}));
|
||||
const maskedValue = '\u2022\u2022\u2022\u2022';
|
||||
const testFlareSolverrMock = vi.hoisted(() => vi.fn());
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
@@ -94,6 +95,40 @@ describe('Admin settings test routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects Plex test when URL or token is missing', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: '', token: 'token' }) };
|
||||
const { POST } = await import('@/app/api/admin/settings/test-plex/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/URL and token are required/);
|
||||
});
|
||||
|
||||
it('rejects Plex test when masked token is missing in storage', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce(null);
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://plex', token: maskedValue }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-plex/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/No stored token/);
|
||||
});
|
||||
|
||||
it('returns error when Plex connection test fails', async () => {
|
||||
plexServiceMock.testConnection.mockResolvedValueOnce({ success: false, message: 'bad token' });
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://plex', token: 'token' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-plex/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/bad token/);
|
||||
});
|
||||
|
||||
it('tests Prowlarr connection', async () => {
|
||||
prowlarrMock.getIndexers.mockResolvedValueOnce([{ id: 1, name: 'Indexer', protocol: 'torrent', enable: true }]);
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) };
|
||||
@@ -105,6 +140,40 @@ describe('Admin settings test routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects Prowlarr test when URL or API key is missing', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr' }) };
|
||||
const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/URL and API key are required/);
|
||||
});
|
||||
|
||||
it('rejects masked Prowlarr API key when no stored key exists', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce(null);
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: maskedValue }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/No stored API key/);
|
||||
});
|
||||
|
||||
it('returns error when Prowlarr test fails', async () => {
|
||||
prowlarrMock.getIndexers.mockRejectedValueOnce(new Error('prowlarr down'));
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/prowlarr down/);
|
||||
});
|
||||
|
||||
it('tests download client connection', async () => {
|
||||
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0');
|
||||
const request = {
|
||||
@@ -252,6 +321,40 @@ describe('Admin settings test routes', () => {
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects FlareSolverr test when URL is missing', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({}) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/URL is required/);
|
||||
});
|
||||
|
||||
it('rejects FlareSolverr test when URL has invalid scheme', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'ftp://flare' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/must start with http/);
|
||||
});
|
||||
|
||||
it('returns error when FlareSolverr test throws', async () => {
|
||||
testFlareSolverrMock.mockRejectedValueOnce(new Error('flare down'));
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.message).toMatch(/flare down/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,105 @@ describe('Admin users routes', () => {
|
||||
expect(payload.user.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('rejects invalid roles', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'owner' }) };
|
||||
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid role/);
|
||||
});
|
||||
|
||||
it('rejects invalid autoApproveRequests values', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'user', autoApproveRequests: 'yes' }) };
|
||||
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/autoApproveRequests/);
|
||||
});
|
||||
|
||||
it('prevents changing your own role', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'user' }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await PUT(request as any, { params: Promise.resolve({ id: 'admin-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/cannot change your own role/i);
|
||||
});
|
||||
|
||||
it('returns 404 when updating a missing user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
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: 'missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/User not found/);
|
||||
});
|
||||
|
||||
it('prevents modifying deleted users', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'local',
|
||||
plexUsername: 'user',
|
||||
deletedAt: new Date(),
|
||||
role: 'user',
|
||||
});
|
||||
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(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/deleted user/);
|
||||
});
|
||||
|
||||
it('prevents changing setup admin role', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
isSetupAdmin: true,
|
||||
authProvider: 'local',
|
||||
plexUsername: 'setup-admin',
|
||||
deletedAt: null,
|
||||
role: 'admin',
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'user' }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await PUT(request as any, { params: Promise.resolve({ id: 'setup-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/setup admin role/);
|
||||
});
|
||||
|
||||
it('rejects admin role with autoApproveRequests false', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'local',
|
||||
plexUsername: 'admin',
|
||||
deletedAt: null,
|
||||
role: 'admin',
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'admin', autoApproveRequests: false }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await PUT(request as any, { params: Promise.resolve({ id: 'admin-2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/auto-approve requests/);
|
||||
});
|
||||
|
||||
it('allows autoApproveRequests update for OIDC users without role change', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
isSetupAdmin: false,
|
||||
@@ -151,6 +250,80 @@ describe('Admin users routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('prevents deleting yourself', async () => {
|
||||
const { DELETE } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'admin-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/delete your own account/);
|
||||
});
|
||||
|
||||
it('returns 404 when deleting a missing user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/User not found/);
|
||||
});
|
||||
|
||||
it('returns 400 when deleting an already deleted user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'u4',
|
||||
plexUsername: 'user',
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'local',
|
||||
deletedAt: new Date(),
|
||||
_count: { requests: 1 },
|
||||
});
|
||||
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/already been deleted/);
|
||||
});
|
||||
|
||||
it('prevents deleting setup admin users', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'setup-1',
|
||||
plexUsername: 'setup-admin',
|
||||
isSetupAdmin: true,
|
||||
authProvider: 'local',
|
||||
deletedAt: null,
|
||||
_count: { requests: 2 },
|
||||
});
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'setup-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/setup admin/);
|
||||
});
|
||||
|
||||
it('prevents deleting non-local users', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'plex-1',
|
||||
plexUsername: 'plexuser',
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'plex',
|
||||
deletedAt: null,
|
||||
_count: { requests: 0 },
|
||||
});
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'plex-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/Cannot delete Plex users/);
|
||||
});
|
||||
|
||||
it('approves a pending user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'u5',
|
||||
@@ -166,6 +339,49 @@ describe('Admin users routes', () => {
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 404 when approving a missing user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
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: 'missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('returns 400 when user is not pending approval', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'u6',
|
||||
plexUsername: 'user',
|
||||
registrationStatus: 'approved',
|
||||
});
|
||||
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: 'u6' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/not pending/);
|
||||
});
|
||||
|
||||
it('rejects a pending user when approve is false', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'u7',
|
||||
plexUsername: 'user',
|
||||
registrationStatus: 'pending_approval',
|
||||
});
|
||||
prismaMock.user.delete.mockResolvedValueOnce({});
|
||||
const request = { json: vi.fn().mockResolvedValue({ approve: false }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/users/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'u7' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.message).toMatch(/rejected/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -94,6 +94,16 @@ describe('Audiobooks browse routes', () => {
|
||||
expect(payload.audiobooks[0].coverArtUrl).toBe('/api/cache/thumbnails/asin.jpg');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid new releases pagination', async () => {
|
||||
const { GET } = await import('@/app/api/audiobooks/new-releases/route');
|
||||
|
||||
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=0') } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns new release audiobooks', async () => {
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.audibleCache.count.mockResolvedValueOnce(0);
|
||||
@@ -106,6 +116,54 @@ describe('Audiobooks browse routes', () => {
|
||||
expect(payload.count).toBe(0);
|
||||
});
|
||||
|
||||
it('enriches new releases and uses 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.2',
|
||||
genres: ['Fiction'],
|
||||
lastSyncedAt: new Date('2024-01-02'),
|
||||
},
|
||||
]);
|
||||
prismaMock.audibleCache.count.mockResolvedValueOnce(1);
|
||||
currentUserMock.mockReturnValue({ sub: 'user-1' });
|
||||
enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', available: true }]);
|
||||
|
||||
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(enrichMock).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
asin: 'ASIN',
|
||||
coverArtUrl: '/api/cache/thumbnails/asin.jpg',
|
||||
}),
|
||||
],
|
||||
'user-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 500 when new releases query fails', async () => {
|
||||
prismaMock.audibleCache.findMany.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toBe('FetchError');
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -117,6 +175,38 @@ describe('Audiobooks browse routes', () => {
|
||||
expect(payload.audiobook.asin).toBe('ASIN123456');
|
||||
});
|
||||
|
||||
it('returns 400 when ASIN is invalid', async () => {
|
||||
const { GET } = await import('@/app/api/audiobooks/[asin]/route');
|
||||
|
||||
const response = await GET({} as any, { params: Promise.resolve({ asin: 'BAD' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 404 when audiobook is not found', async () => {
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValue(null);
|
||||
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(response.status).toBe(404);
|
||||
expect(payload.error).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('returns 500 when audiobook lookup fails', async () => {
|
||||
audibleServiceMock.getAudiobookDetails.mockRejectedValue(new Error('fail'));
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toBe('FetchError');
|
||||
});
|
||||
|
||||
it('returns cached covers for login', async () => {
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN', title: 'Title', author: 'Author', cachedCoverPath: '/tmp/asin.jpg', coverArtUrl: null },
|
||||
|
||||
@@ -15,6 +15,9 @@ const jobQueueMock = vi.hoisted(() => ({
|
||||
addNotificationJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
const findPlexMatchMock = vi.hoisted(() => vi.fn());
|
||||
const audibleServiceMock = vi.hoisted(() => ({
|
||||
getAudiobookDetails: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
@@ -33,9 +36,7 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||
getAudibleService: () => ({
|
||||
getAudiobookDetails: vi.fn().mockResolvedValue(null),
|
||||
}),
|
||||
getAudibleService: () => audibleServiceMock,
|
||||
}));
|
||||
|
||||
describe('Request with torrent route', () => {
|
||||
@@ -46,6 +47,7 @@ describe('Request with torrent route', () => {
|
||||
json: vi.fn(),
|
||||
};
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('returns 409 when audiobook is already being processed', async () => {
|
||||
@@ -68,6 +70,216 @@ describe('Request with torrent route', () => {
|
||||
expect(payload.error).toBe('BeingProcessed');
|
||||
});
|
||||
|
||||
it('returns 401 when user is missing', async () => {
|
||||
authRequest.user = null;
|
||||
|
||||
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(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 409 when audiobook is already available', 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: 'available',
|
||||
userId: 'user-2',
|
||||
user: { plexUsername: 'other' },
|
||||
} as any);
|
||||
|
||||
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('AlreadyAvailable');
|
||||
});
|
||||
|
||||
it('returns 409 when Plex match already exists', 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({ plexGuid: 'plex://item' });
|
||||
|
||||
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('AlreadyAvailable');
|
||||
expect(payload.plexGuid).toBe('plex://item');
|
||||
});
|
||||
|
||||
it('returns 409 for duplicate active requests', 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({ id: 'ab-1', title: 'Title', author: 'Author' } as any);
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-2', status: 'pending' } as any);
|
||||
|
||||
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('DuplicateRequest');
|
||||
});
|
||||
|
||||
it('deletes failed requests before creating a new one', 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({ id: 'ab-1', title: 'Title', author: 'Author' } as any);
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-old', status: 'failed' } as any);
|
||||
prismaMock.request.delete.mockResolvedValueOnce({} as any);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: true,
|
||||
plexUsername: 'user',
|
||||
} as any);
|
||||
prismaMock.request.create.mockResolvedValueOnce({
|
||||
id: 'req-3',
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
|
||||
user: { id: 'user-1', plexUsername: 'user' },
|
||||
} as any);
|
||||
|
||||
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(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-old' } });
|
||||
});
|
||||
|
||||
it('returns 404 when user lookup fails', 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({ id: 'ab-1', title: 'Title', author: 'Author' } as any);
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
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(404);
|
||||
expect(payload.error).toBe('UserNotFound');
|
||||
});
|
||||
|
||||
it('stores selected torrent when approval is required', 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({ id: 'ab-1', title: 'Title', author: 'Author' } as any);
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: false,
|
||||
plexUsername: 'user',
|
||||
} as any);
|
||||
prismaMock.request.create.mockResolvedValueOnce({
|
||||
id: 'req-4',
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
user: { id: 'user-1', plexUsername: 'user' },
|
||||
} as any);
|
||||
|
||||
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(prismaMock.request.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'awaiting_approval',
|
||||
selectedTorrent: expect.objectContaining({ guid: 'guid' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled();
|
||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
|
||||
'request_pending_approval',
|
||||
'req-4',
|
||||
'Title',
|
||||
'Author',
|
||||
'user'
|
||||
);
|
||||
});
|
||||
|
||||
it('updates year from Audnexus when audiobook already exists', 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);
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({ releaseDate: '2020-01-02' });
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce({ id: 'ab-2', title: 'Title', author: 'Author' } as any);
|
||||
prismaMock.audiobook.update.mockResolvedValueOnce({ id: 'ab-2', year: 2020 } as any);
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-1',
|
||||
role: 'admin',
|
||||
autoApproveRequests: null,
|
||||
plexUsername: 'user',
|
||||
} as any);
|
||||
prismaMock.request.create.mockResolvedValueOnce({
|
||||
id: 'req-5',
|
||||
audiobook: { id: 'ab-2', title: 'Title', author: 'Author' },
|
||||
user: { id: 'user-1', plexUsername: 'user' },
|
||||
} as any);
|
||||
|
||||
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(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ year: 2020 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns validation errors for invalid payloads', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { title: 'Missing fields' },
|
||||
});
|
||||
|
||||
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(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('creates request and queues download job', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' },
|
||||
@@ -104,4 +316,3 @@ describe('Request with torrent route', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -95,6 +95,50 @@ describe('Auth misc routes', () => {
|
||||
expect(payload.accessToken).toBe('access-token');
|
||||
});
|
||||
|
||||
it('returns 400 when refresh token is missing', async () => {
|
||||
const { POST } = await import('@/app/api/auth/refresh/route');
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({}) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 401 when refresh token is invalid', async () => {
|
||||
verifyRefreshTokenMock.mockReturnValue(null);
|
||||
const { POST } = await import('@/app/api/auth/refresh/route');
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({ refreshToken: 'bad' }) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 401 when user is not found for refresh token', async () => {
|
||||
verifyRefreshTokenMock.mockReturnValue({ sub: 'user-missing' });
|
||||
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
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(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 500 when refresh token verification throws', async () => {
|
||||
verifyRefreshTokenMock.mockImplementation(() => {
|
||||
throw new Error('bad 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(500);
|
||||
expect(payload.error).toBe('RefreshError');
|
||||
});
|
||||
|
||||
it('returns provider info for audiobookshelf mode', async () => {
|
||||
configServiceMock.get
|
||||
.mockResolvedValueOnce('audiobookshelf')
|
||||
|
||||
@@ -38,6 +38,27 @@ describe('OIDC auth routes', () => {
|
||||
expect(response.headers.get('location')).toBe('http://oidc/login');
|
||||
});
|
||||
|
||||
it('returns error when OIDC login URL is missing', async () => {
|
||||
authProviderMock.initiateLogin.mockResolvedValue({});
|
||||
const { GET } = await import('@/app/api/auth/oidc/login/route');
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Failed to generate authorization URL/);
|
||||
});
|
||||
|
||||
it('redirects to login when OIDC login initiation fails', async () => {
|
||||
authProviderMock.initiateLogin.mockRejectedValue(new Error('boom'));
|
||||
const { GET } = await import('@/app/api/auth/oidc/login/route');
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get('location')).toContain('/login?error=');
|
||||
});
|
||||
|
||||
it('redirects to login on missing code/state', async () => {
|
||||
const { GET } = await import('@/app/api/auth/oidc/callback/route');
|
||||
|
||||
|
||||
@@ -278,6 +278,64 @@ describe('Plex auth routes', () => {
|
||||
expect(payload.users).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects Plex home users when token is missing', async () => {
|
||||
const { GET } = await import('@/app/api/auth/plex/home-users/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 500 when Plex home users fetch fails', async () => {
|
||||
plexServiceMock.getHomeUsers.mockRejectedValue(new Error('boom'));
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toBe('ServerError');
|
||||
});
|
||||
|
||||
it('rejects profile switch without main account token', async () => {
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ userId: 'home-1' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects profile switch when userId is missing', async () => {
|
||||
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({});
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 401 for invalid profile PIN', async () => {
|
||||
plexServiceMock.switchHomeUser.mockRejectedValue(new Error('Invalid PIN'));
|
||||
|
||||
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: '0000' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('InvalidPIN');
|
||||
});
|
||||
|
||||
it('switches Plex profile using provided profile info', async () => {
|
||||
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
||||
prismaMock.user.count.mockResolvedValue(1);
|
||||
@@ -304,6 +362,51 @@ describe('Plex auth routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.accessToken).toBe('access-token');
|
||||
});
|
||||
|
||||
it('switches Plex profile using getUserInfo fallback', async () => {
|
||||
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({
|
||||
id: 'plex-3',
|
||||
username: 'Fallback',
|
||||
email: 'user@example.com',
|
||||
thumb: '/avatar',
|
||||
});
|
||||
prismaMock.user.count.mockResolvedValue(0);
|
||||
prismaMock.user.upsert.mockResolvedValue({
|
||||
id: 'user-3',
|
||||
plexId: 'plex-3',
|
||||
plexUsername: 'Fallback',
|
||||
plexEmail: 'user@example.com',
|
||||
role: 'admin',
|
||||
avatarUrl: '/avatar',
|
||||
});
|
||||
|
||||
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-2', pin: '1234' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.user.plexId).toBe('plex-3');
|
||||
expect(payload.user.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns 500 when profile info lookup fails', async () => {
|
||||
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({ id: 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-2', pin: '1234' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toBe('ServerError');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Component: BookDate Library Route Tests
|
||||
* Documentation: documentation/features/bookdate.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 configMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getPlexConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
describe('BookDate library route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'user-1' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
});
|
||||
|
||||
it('returns 400 when Audiobookshelf library ID is missing', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue(null);
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/library/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Audiobookshelf library ID/i);
|
||||
});
|
||||
|
||||
it('returns 400 when Plex library ID is missing', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: null });
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/library/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Plex library ID/i);
|
||||
});
|
||||
|
||||
it('returns books with cover priority (library cache > audible cache > null)', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'lib-1' });
|
||||
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'book-1',
|
||||
title: 'Cached Cover',
|
||||
author: 'Author A',
|
||||
asin: 'ASIN1',
|
||||
cachedLibraryCoverPath: '/cache/library/cover1.jpg',
|
||||
},
|
||||
{
|
||||
id: 'book-2',
|
||||
title: 'Audible Cover',
|
||||
author: 'Author B',
|
||||
asin: 'ASIN2',
|
||||
cachedLibraryCoverPath: null,
|
||||
},
|
||||
{
|
||||
id: 'book-3',
|
||||
title: 'No Cover',
|
||||
author: 'Author C',
|
||||
asin: null,
|
||||
cachedLibraryCoverPath: null,
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.audibleCache.findMany.mockResolvedValue([
|
||||
{ asin: 'ASIN1', coverArtUrl: 'http://audible/cover1.jpg' },
|
||||
{ asin: 'ASIN2', coverArtUrl: 'http://audible/cover2.jpg' },
|
||||
]);
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/library/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.books).toEqual([
|
||||
{
|
||||
id: 'book-1',
|
||||
title: 'Cached Cover',
|
||||
author: 'Author A',
|
||||
coverUrl: '/api/cache/library/cover1.jpg',
|
||||
},
|
||||
{
|
||||
id: 'book-2',
|
||||
title: 'Audible Cover',
|
||||
author: 'Author B',
|
||||
coverUrl: 'http://audible/cover2.jpg',
|
||||
},
|
||||
{
|
||||
id: 'book-3',
|
||||
title: 'No Cover',
|
||||
author: 'Author C',
|
||||
coverUrl: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns 500 when database lookup fails', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'lib-1' });
|
||||
prismaMock.plexLibrary.findMany.mockRejectedValue(new Error('db down'));
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/library/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/db down/i);
|
||||
});
|
||||
});
|
||||
@@ -355,6 +355,61 @@ describe('BookDate routes', () => {
|
||||
expect(payload.recommendations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 500 when AI response is missing recommendations', 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({ invalid: true });
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/recommendations/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Invalid AI response format/);
|
||||
});
|
||||
|
||||
it('returns generated response even when no valid recommendations match', 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 only' }, { author: 'Author only' }],
|
||||
});
|
||||
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]);
|
||||
(prismaMock.bookDateRecommendation as any).createMany = vi.fn();
|
||||
|
||||
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(payload.generatedCount).toBe(0);
|
||||
expect((prismaMock.bookDateRecommendation as any).createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when generating recommendations without config', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
@@ -395,6 +450,49 @@ describe('BookDate routes', () => {
|
||||
expect(payload.error).toMatch(/Could not find any new recommendations/i);
|
||||
});
|
||||
|
||||
it('returns 404 when user is missing during generation', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
|
||||
isVerified: true,
|
||||
isEnabled: true,
|
||||
provider: 'openai',
|
||||
model: 'gpt',
|
||||
apiKey: 'enc-key',
|
||||
baseUrl: null,
|
||||
});
|
||||
prismaMock.user.findUnique.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(/User not found/);
|
||||
});
|
||||
|
||||
it('returns 500 when generate receives invalid AI response', 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({ invalid: true });
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/generate/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Invalid AI response format/);
|
||||
});
|
||||
|
||||
it('stores generated recommendations from the AI', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
|
||||
isVerified: true,
|
||||
@@ -436,6 +534,112 @@ describe('BookDate routes', () => {
|
||||
expect(payload.recommendations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 400 when swipe payload is missing fields', async () => {
|
||||
authRequest.json.mockResolvedValue({ action: 'left' });
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/swipe/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/recommendationId and action/);
|
||||
});
|
||||
|
||||
it('returns 400 when swipe action is invalid', async () => {
|
||||
authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'sideways' });
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/swipe/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid action/);
|
||||
});
|
||||
|
||||
it('returns 404 when recommendation is missing', async () => {
|
||||
authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'left' });
|
||||
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/swipe/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/Recommendation not found/);
|
||||
});
|
||||
|
||||
it('does not create a request when right swipe is marked as known', async () => {
|
||||
authRequest.json.mockResolvedValue({ recommendationId: 'rec-known', action: 'right', markedAsKnown: true });
|
||||
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
|
||||
id: 'rec-known',
|
||||
userId: 'user-1',
|
||||
title: 'Known Book',
|
||||
author: 'Known Author',
|
||||
audnexusAsin: 'ASIN-KNOWN',
|
||||
} as any);
|
||||
|
||||
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(prismaMock.request.create).not.toHaveBeenCalled();
|
||||
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records left swipe without creating a request', async () => {
|
||||
authRequest.json.mockResolvedValue({ recommendationId: 'rec-left', action: 'left' });
|
||||
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
|
||||
id: 'rec-left',
|
||||
userId: 'user-1',
|
||||
title: 'Left Book',
|
||||
author: 'Left Author',
|
||||
audnexusAsin: 'ASIN-LEFT',
|
||||
} as any);
|
||||
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
|
||||
|
||||
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(prismaMock.request.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates existing audiobook year when Audnexus provides releaseDate', async () => {
|
||||
authRequest.json.mockResolvedValue({ recommendationId: 'rec-year', action: 'right', markedAsKnown: false });
|
||||
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
|
||||
id: 'rec-year',
|
||||
userId: 'user-1',
|
||||
title: 'Year Book',
|
||||
author: 'Year Author',
|
||||
audnexusAsin: 'ASIN-YEAR',
|
||||
} as any);
|
||||
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({
|
||||
releaseDate: '2020-01-15',
|
||||
});
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce({
|
||||
id: 'ab-year',
|
||||
title: 'Year Book',
|
||||
author: 'Year Author',
|
||||
audibleAsin: 'ASIN-YEAR',
|
||||
} as any);
|
||||
prismaMock.audiobook.update.mockResolvedValueOnce({ id: 'ab-year' } as any);
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-existing' } as any);
|
||||
|
||||
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(prismaMock.audiobook.update).toHaveBeenCalledWith({
|
||||
where: { id: 'ab-year' },
|
||||
data: { year: 2020 },
|
||||
});
|
||||
expect(prismaMock.request.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records swipe and creates request on right swipe (admin auto-approves)', async () => {
|
||||
authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'right', markedAsKnown: false });
|
||||
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
|
||||
@@ -665,4 +869,3 @@ describe('BookDate routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,40 @@ describe('Config API routes', () => {
|
||||
expect(configServiceMock.setMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 when configuration update payload is invalid', async () => {
|
||||
const { PUT } = await import('@/app/api/config/route');
|
||||
const response = await PUT({ json: vi.fn().mockResolvedValue({}) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Validation error/);
|
||||
});
|
||||
|
||||
it('returns 500 when configuration update fails', async () => {
|
||||
configServiceMock.setMany.mockRejectedValueOnce(new Error('db down'));
|
||||
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(500);
|
||||
expect(payload.error).toMatch(/Failed to update configuration/);
|
||||
});
|
||||
|
||||
it('returns 500 when configuration lookup fails', async () => {
|
||||
configServiceMock.getAll.mockRejectedValueOnce(new Error('db down'));
|
||||
const { GET } = await import('@/app/api/config/route');
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Failed to get configuration/);
|
||||
});
|
||||
|
||||
it('returns category configuration', async () => {
|
||||
configServiceMock.getCategory.mockResolvedValue({ plex_url: 'http://plex' });
|
||||
const { GET } = await import('@/app/api/config/[category]/route');
|
||||
@@ -54,6 +88,17 @@ describe('Config API routes', () => {
|
||||
expect(payload.category).toBe('plex');
|
||||
expect(payload.config.plex_url).toBe('http://plex');
|
||||
});
|
||||
|
||||
it('returns 500 when category configuration lookup fails', async () => {
|
||||
configServiceMock.getCategory.mockRejectedValueOnce(new Error('db down'));
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Failed to get configuration/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -110,6 +110,60 @@ describe('Request action routes', () => {
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when manual search user is not authenticated', async () => {
|
||||
authRequest.user = null;
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/manual-search/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-auth' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 404 when manual search request is missing', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/manual-search/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).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('returns 403 when manual search request is not owned', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-9',
|
||||
userId: 'user-2',
|
||||
status: 'failed',
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/manual-search/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-9' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('returns 400 when manual search status is not eligible', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-10',
|
||||
userId: 'user-1',
|
||||
status: 'downloading',
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/manual-search/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-10' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('selects a torrent and queues download', async () => {
|
||||
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
@@ -134,6 +188,134 @@ describe('Request action routes', () => {
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when user is not authenticated', async () => {
|
||||
authRequest.user = null;
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-auth' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 400 when torrent data is missing', async () => {
|
||||
authRequest.json.mockResolvedValue({});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-missing-torrent' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 404 when request is not found', async () => {
|
||||
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/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).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('returns 403 when user does not own the request', async () => {
|
||||
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-4',
|
||||
userId: 'user-2',
|
||||
status: 'awaiting_search',
|
||||
audiobook: { id: 'ab-2', title: 'Title', author: 'Author' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('returns 403 when request is awaiting approval', async () => {
|
||||
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-5',
|
||||
userId: 'user-1',
|
||||
status: 'awaiting_approval',
|
||||
audiobook: { id: 'ab-2', title: 'Title', author: 'Author' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-5' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('AwaitingApproval');
|
||||
});
|
||||
|
||||
it('stores selected torrent when approval is required by global setting', async () => {
|
||||
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||
configState.values.set('auto_approve_requests', 'false');
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-6',
|
||||
userId: 'user-1',
|
||||
status: 'awaiting_search',
|
||||
audiobook: { id: 'ab-3', title: 'Title', author: 'Author' },
|
||||
} as any);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: null,
|
||||
plexUsername: 'plexuser',
|
||||
} as any);
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-6',
|
||||
status: 'awaiting_approval',
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-6' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.message).toMatch(/approval/i);
|
||||
expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled();
|
||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('auto-approves when global setting is missing and user has no preference', async () => {
|
||||
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-7',
|
||||
userId: 'user-1',
|
||||
status: 'awaiting_search',
|
||||
audiobook: { id: 'ab-4', title: 'Title', author: 'Author' },
|
||||
} as any);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: null,
|
||||
plexUsername: 'plexuser',
|
||||
} as any);
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-7',
|
||||
status: 'downloading',
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-7' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when ebook sidecar is disabled', async () => {
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Component: Setup Status API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
describe('Setup status route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns true when setup_completed is true', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce({
|
||||
key: 'setup_completed',
|
||||
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('returns false when setup_completed is missing', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { GET } = await import('@/app/api/setup/status/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.setupComplete).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the database lookup fails', async () => {
|
||||
prismaMock.configuration.findUnique.mockRejectedValueOnce(new Error('db not ready'));
|
||||
|
||||
const { GET } = await import('@/app/api/setup/status/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.setupComplete).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,55 @@ describe('Setup test routes', () => {
|
||||
expect(payload.libraries[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('returns 400 when Plex url or token is missing', async () => {
|
||||
const { POST } = await import('@/app/api/setup/test-plex/route');
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://plex' }) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/URL and token/);
|
||||
});
|
||||
|
||||
it('returns 400 when Plex connection fails', async () => {
|
||||
plexServiceMock.testConnection.mockResolvedValue({
|
||||
success: false,
|
||||
message: 'bad token',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/setup/test-plex/route');
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://plex', token: 'bad' }) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/bad token/);
|
||||
});
|
||||
|
||||
it('returns 400 when Plex info is missing', async () => {
|
||||
plexServiceMock.testConnection.mockResolvedValue({
|
||||
success: true,
|
||||
info: null,
|
||||
message: 'missing info',
|
||||
});
|
||||
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/missing info/);
|
||||
});
|
||||
|
||||
it('returns 500 when Plex test throws', async () => {
|
||||
plexServiceMock.testConnection.mockRejectedValue(new Error('connection error'));
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/connection error/);
|
||||
});
|
||||
|
||||
it('tests qBittorrent credentials', async () => {
|
||||
qbtMock.testConnectionWithCredentials.mockResolvedValue('4.0.0');
|
||||
|
||||
@@ -103,6 +152,28 @@ describe('Setup test routes', () => {
|
||||
expect(payload.version).toBe('4.0.0');
|
||||
});
|
||||
|
||||
it('rejects invalid download client type', async () => {
|
||||
const { POST } = await import('@/app/api/setup/test-download-client/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({ type: 'transmission', url: 'http://transmission' }),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid client type/);
|
||||
});
|
||||
|
||||
it('rejects missing qBittorrent credentials', async () => {
|
||||
const { POST } = await import('@/app/api/setup/test-download-client/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({ type: 'qbittorrent', url: 'http://qbt' }),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Username and password/);
|
||||
});
|
||||
|
||||
it('tests SABnzbd connection', async () => {
|
||||
sabnzbdMock.testConnection.mockResolvedValue({ success: true, version: '3.0' });
|
||||
|
||||
@@ -120,6 +191,23 @@ describe('Setup test routes', () => {
|
||||
expect(payload.version).toBe('3.0');
|
||||
});
|
||||
|
||||
it('returns error when SABnzbd connection fails', async () => {
|
||||
sabnzbdMock.testConnection.mockResolvedValue({ success: false, error: 'bad key' });
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/bad key/);
|
||||
});
|
||||
|
||||
it('tests Prowlarr indexers', async () => {
|
||||
prowlarrMock.getIndexers.mockResolvedValue([
|
||||
{ id: 1, name: 'Indexer', protocol: 'torrent', enable: true, capabilities: {} },
|
||||
@@ -161,6 +249,72 @@ describe('Setup test routes', () => {
|
||||
expect(payload.issuer.authorizationEndpoint).toBe('http://issuer/auth');
|
||||
});
|
||||
|
||||
it('returns error when OIDC fields are missing', async () => {
|
||||
const { POST } = await import('@/app/api/setup/test-oidc/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({ issuerUrl: 'http://issuer', clientId: 'client' }),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/required/);
|
||||
});
|
||||
|
||||
it('returns error when OIDC issuer URL is invalid', async () => {
|
||||
const { POST } = await import('@/app/api/setup/test-oidc/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
issuerUrl: 'not a url',
|
||||
clientId: 'client',
|
||||
clientSecret: 'secret',
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid issuer URL/);
|
||||
});
|
||||
|
||||
it('returns error when OIDC issuer metadata is incomplete', async () => {
|
||||
issuerMock.discover.mockResolvedValue({
|
||||
issuer: 'http://issuer',
|
||||
metadata: {
|
||||
token_endpoint: 'http://issuer/token',
|
||||
userinfo_endpoint: 'http://issuer/user',
|
||||
},
|
||||
});
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/missing required endpoints/);
|
||||
});
|
||||
|
||||
it('returns friendly error when OIDC discovery fails to resolve host', async () => {
|
||||
issuerMock.discover.mockRejectedValue(new Error('getaddrinfo ENOTFOUND issuer'));
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Cannot reach OIDC provider/);
|
||||
});
|
||||
|
||||
it('validates paths are writable', async () => {
|
||||
fsMock.access.mockRejectedValueOnce(new Error('missing'));
|
||||
fsMock.mkdir.mockResolvedValueOnce(undefined);
|
||||
@@ -280,6 +434,63 @@ describe('Setup test routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.libraries[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('returns error when Audiobookshelf server URL is missing', async () => {
|
||||
const { POST } = await import('@/app/api/setup/test-abs/route');
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({}) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Server URL/);
|
||||
});
|
||||
|
||||
it('returns error when saved Audiobookshelf token is missing', async () => {
|
||||
configServiceMock.get.mockResolvedValueOnce(null);
|
||||
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/API token is required/);
|
||||
});
|
||||
|
||||
it('returns error when Audiobookshelf connection fails', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
});
|
||||
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: 'token' }),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Connection failed/);
|
||||
});
|
||||
|
||||
it('returns error when Audiobookshelf response is missing libraries', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
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: 'token' }),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid response/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user