/** * Component: Request By ID API Route Tests * Documentation: documentation/testing.md */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPrismaMock } from '../helpers/prisma'; let authRequest: any; const prismaMock = createPrismaMock(); const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob: vi.fn() })); const requireAuthMock = vi.hoisted(() => vi.fn()); const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() })); const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() })); vi.mock('@/lib/db', () => ({ prisma: prismaMock, })); vi.mock('@/lib/services/job-queue.service', () => ({ getJobQueueService: () => jobQueueMock, })); vi.mock('@/lib/integrations/qbittorrent.service', () => ({ getQBittorrentService: async () => qbtMock, })); vi.mock('@/lib/integrations/sabnzbd.service', () => ({ getSABnzbdService: async () => sabnzbdMock, })); vi.mock('@/lib/middleware/auth', () => ({ requireAuth: requireAuthMock, })); describe('Request by ID API routes', () => { beforeEach(() => { vi.clearAllMocks(); authRequest = { user: { id: 'user-1', role: 'user' }, nextUrl: new URL('http://localhost/api/requests/req-1'), json: vi.fn(), }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); }); it('returns 403 when user is not authorized to view the request', async () => { prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-1', userId: 'user-2', }); const { GET } = await import('@/app/api/requests/[id]/route'); const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) }); const payload = await response.json(); expect(response.status).toBe(403); expect(payload.error).toBe('Forbidden'); }); it('returns request details for the owner', async () => { prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-1', userId: 'user-1', audiobook: { id: 'ab-1' }, }); const { GET } = await import('@/app/api/requests/[id]/route'); const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) }); const payload = await response.json(); expect(response.status).toBe(200); expect(payload.success).toBe(true); expect(payload.request.id).toBe('req-1'); }); it('returns 404 when request does not exist', async () => { prismaMock.request.findFirst.mockResolvedValueOnce(null); const { GET } = await import('@/app/api/requests/[id]/route'); const response = await GET({} as any, { params: Promise.resolve({ id: 'missing' }) }); const payload = await response.json(); expect(response.status).toBe(404); expect(payload.error).toBe('NotFound'); }); it('returns 401 when user is missing', async () => { authRequest.user = null; const { GET } = await import('@/app/api/requests/[id]/route'); const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) }); const payload = await response.json(); expect(response.status).toBe(401); expect(payload.error).toBe('Unauthorized'); }); it('cancels a request', async () => { authRequest.json.mockResolvedValue({ action: 'cancel' }); prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-2', userId: 'user-1', status: 'pending', }); prismaMock.request.update.mockResolvedValueOnce({ id: 'req-2', status: 'cancelled', audiobook: { id: 'ab-1' }, }); const { PATCH } = await import('@/app/api/requests/[id]/route'); const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-2' }) }); const payload = await response.json(); expect(response.status).toBe(200); expect(payload.request.status).toBe('cancelled'); }); it('returns 400 for invalid actions', async () => { authRequest.json.mockResolvedValue({ action: 'unknown' }); prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-2', userId: 'user-1', status: 'pending', }); const { PATCH } = await import('@/app/api/requests/[id]/route'); const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-2' }) }); const payload = await response.json(); expect(response.status).toBe(400); expect(payload.error).toBe('ValidationError'); }); it('rejects retry when status is not retryable', async () => { authRequest.json.mockResolvedValue({ action: 'retry' }); prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-4', userId: 'user-1', status: 'available', }); const { PATCH } = await import('@/app/api/requests/[id]/route'); const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-4' }) }); const payload = await response.json(); expect(response.status).toBe(400); expect(payload.error).toBe('ValidationError'); }); it('retries a failed request by enqueuing a search job', async () => { authRequest.json.mockResolvedValue({ action: 'retry' }); prismaMock.request.findFirst .mockResolvedValueOnce({ id: 'req-3', userId: 'user-1', status: 'failed', }) .mockResolvedValueOnce({ id: 'req-3', userId: 'user-1', audiobook: { id: 'ab-2', title: 'Title', author: 'Author', audibleAsin: 'ASIN-2', }, }); prismaMock.request.update.mockResolvedValueOnce({ id: 'req-3', status: 'pending', audiobook: { id: 'ab-2' }, }); const { PATCH } = await import('@/app/api/requests/[id]/route'); const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-3' }) }); const payload = await response.json(); expect(response.status).toBe(200); expect(payload.success).toBe(true); expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-3', { id: 'ab-2', title: 'Title', author: 'Author', asin: 'ASIN-2', }); }); it('retries an import via qBittorrent download history', async () => { authRequest.json.mockResolvedValue({ action: 'retry' }); prismaMock.request.findFirst .mockResolvedValueOnce({ id: 'req-5', userId: 'user-1', status: 'warn', }) .mockResolvedValueOnce({ id: 'req-5', userId: 'user-1', audiobook: { id: 'ab-5' }, downloadHistory: [{ torrentHash: 'hash-1', selected: true }], }); qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' }); prismaMock.request.update.mockResolvedValueOnce({ id: 'req-5', status: 'processing', audiobook: { id: 'ab-5' }, }); const { PATCH } = await import('@/app/api/requests/[id]/route'); const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-5' }) }); const payload = await response.json(); expect(response.status).toBe(200); expect(payload.success).toBe(true); expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith('req-5', 'ab-5', '/downloads/Book'); }); it('retries an import via SABnzbd download history', async () => { authRequest.json.mockResolvedValue({ action: 'retry' }); prismaMock.request.findFirst .mockResolvedValueOnce({ id: 'req-6', userId: 'user-1', status: 'awaiting_import', }) .mockResolvedValueOnce({ id: 'req-6', userId: 'user-1', audiobook: { id: 'ab-6' }, downloadHistory: [{ nzbId: 'nzb-1', selected: true }], }); sabnzbdMock.getNZB.mockResolvedValue({ downloadPath: '/usenet/book' }); prismaMock.request.update.mockResolvedValueOnce({ id: 'req-6', status: 'processing', audiobook: { id: 'ab-6' }, }); const { PATCH } = await import('@/app/api/requests/[id]/route'); const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-6' }) }); const payload = await response.json(); expect(response.status).toBe(200); expect(payload.success).toBe(true); expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith('req-6', 'ab-6', '/usenet/book'); }); it('returns 400 when download history is missing for import retry', async () => { authRequest.json.mockResolvedValue({ action: 'retry' }); prismaMock.request.findFirst .mockResolvedValueOnce({ id: 'req-7', userId: 'user-1', status: 'warn', }) .mockResolvedValueOnce({ id: 'req-7', userId: 'user-1', audiobook: { id: 'ab-7' }, downloadHistory: [], }); const { PATCH } = await import('@/app/api/requests/[id]/route'); const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-7' }) }); const payload = await response.json(); expect(response.status).toBe(400); expect(payload.error).toBe('ValidationError'); }); it('returns 400 when download client info is missing for import retry', async () => { authRequest.json.mockResolvedValue({ action: 'retry' }); prismaMock.request.findFirst .mockResolvedValueOnce({ id: 'req-8', userId: 'user-1', status: 'warn', }) .mockResolvedValueOnce({ id: 'req-8', userId: 'user-1', audiobook: { id: 'ab-8' }, downloadHistory: [{}], }); const { PATCH } = await import('@/app/api/requests/[id]/route'); const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-8' }) }); const payload = await response.json(); expect(response.status).toBe(400); expect(payload.error).toBe('ValidationError'); }); it('allows admins to delete requests', async () => { authRequest.user = { id: 'admin-1', role: 'admin' }; prismaMock.request.delete.mockResolvedValueOnce({}); const { DELETE } = await import('@/app/api/requests/[id]/route'); const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-4' }) }); const payload = await response.json(); expect(response.status).toBe(200); expect(payload.success).toBe(true); expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-4' } }); }); it('blocks delete for non-admin users', async () => { authRequest.user = { id: 'user-2', role: 'user' }; const { DELETE } = await import('@/app/api/requests/[id]/route'); const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-9' }) }); const payload = await response.json(); expect(response.status).toBe(403); expect(payload.error).toBe('Forbidden'); }); });