/** * Component: BookDate API Route Tests * Documentation: documentation/testing.md */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPrismaMock } from '../helpers/prisma'; let authRequest: any; const prismaMock = createPrismaMock(); const requireAuthMock = vi.hoisted(() => vi.fn()); const requireAdminMock = vi.hoisted(() => vi.fn()); const encryptionMock = vi.hoisted(() => ({ encrypt: vi.fn((value: string) => `enc-${value}`), decrypt: vi.fn((value: string) => value.replace('enc-', '')), })); const configServiceMock = vi.hoisted(() => ({ getBackendMode: vi.fn(), })); const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn().mockResolvedValue(undefined), addNotificationJob: vi.fn(() => Promise.resolve()), })); const bookdateHelpersMock = vi.hoisted(() => ({ buildAIPrompt: vi.fn(), callAI: vi.fn(), matchToAudnexus: vi.fn(), isInLibrary: vi.fn(), isAlreadyRequested: vi.fn(), isAlreadySwiped: vi.fn(), })); const audibleServiceMock = vi.hoisted(() => ({ getAudiobookDetails: vi.fn(), })); vi.mock('@/lib/db', () => ({ prisma: prismaMock, })); vi.mock('@/lib/integrations/audible.service', () => ({ getAudibleService: () => audibleServiceMock, })); vi.mock('@/lib/middleware/auth', () => ({ requireAuth: requireAuthMock, requireAdmin: requireAdminMock, })); vi.mock('@/lib/services/encryption.service', () => ({ getEncryptionService: () => encryptionMock, })); vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => configServiceMock, })); vi.mock('@/lib/services/job-queue.service', () => ({ getJobQueueService: () => jobQueueMock, })); vi.mock('@/lib/bookdate/helpers', () => bookdateHelpersMock); describe('BookDate routes', () => { beforeEach(() => { vi.clearAllMocks(); authRequest = { user: { id: 'user-1', role: 'admin' }, json: vi.fn() }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); requireAdminMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); }); it('returns BookDate config without API key', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ id: 'cfg-1', apiKey: 'secret', provider: 'openai', model: 'gpt', }); const { GET } = await import('@/app/api/bookdate/config/route'); const response = await GET({} as any); const payload = await response.json(); expect(payload.config.apiKey).toBeUndefined(); expect(payload.config.provider).toBe('openai'); }); it('returns null config when not configured', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); const { GET } = await import('@/app/api/bookdate/config/route'); const response = await GET({} as any); const payload = await response.json(); expect(payload.config).toBeNull(); }); it('saves BookDate config and clears recommendations', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); prismaMock.bookDateConfig.create.mockResolvedValueOnce({ id: 'cfg-2', provider: 'openai', model: 'gpt', apiKey: 'enc-secret', }); prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 }); authRequest.json.mockResolvedValue({ provider: 'openai', apiKey: 'secret', model: 'gpt' }); const { POST } = await import('@/app/api/bookdate/config/route'); const response = await POST({} as any); const payload = await response.json(); expect(payload.success).toBe(true); expect(prismaMock.bookDateRecommendation.deleteMany).toHaveBeenCalled(); }); it('rejects missing required fields when saving config', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); authRequest.json.mockResolvedValue({ provider: 'openai', apiKey: 'key' }); const { POST } = await import('@/app/api/bookdate/config/route'); const response = await POST({} as any); const payload = await response.json(); expect(response.status).toBe(400); expect(payload.error).toMatch(/Missing required fields/); }); it('rejects invalid provider when saving config', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); authRequest.json.mockResolvedValue({ provider: 'invalid', apiKey: 'key', model: 'gpt' }); const { POST } = await import('@/app/api/bookdate/config/route'); const response = await POST({} as any); const payload = await response.json(); expect(response.status).toBe(400); expect(payload.error).toMatch(/Invalid provider/); }); it('rejects custom provider without baseUrl', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); authRequest.json.mockResolvedValue({ provider: 'custom', apiKey: '', model: 'model-x' }); const { POST } = await import('@/app/api/bookdate/config/route'); const response = await POST({} as any); const payload = await response.json(); expect(response.status).toBe(400); expect(payload.error).toMatch(/Base URL is required/); }); it('rejects custom provider with invalid baseUrl', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); authRequest.json.mockResolvedValue({ provider: 'custom', apiKey: '', model: 'model-x', baseUrl: 'ftp://bad', }); const { POST } = await import('@/app/api/bookdate/config/route'); const response = await POST({} as any); const payload = await response.json(); expect(response.status).toBe(400); expect(payload.error).toMatch(/Invalid base URL/); }); it('updates existing config without a new API key', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ id: 'cfg-9', apiKey: 'enc-existing', }); prismaMock.bookDateConfig.update.mockResolvedValueOnce({ id: 'cfg-9', provider: 'openai', model: 'gpt-4', apiKey: 'enc-existing', }); authRequest.json.mockResolvedValue({ provider: 'openai', model: 'gpt-4' }); const { POST } = await import('@/app/api/bookdate/config/route'); const response = await POST({} as any); const payload = await response.json(); expect(payload.success).toBe(true); expect(prismaMock.bookDateConfig.update).toHaveBeenCalled(); }); it('creates custom config with empty API key', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); prismaMock.bookDateConfig.create.mockResolvedValueOnce({ id: 'cfg-10', provider: 'custom', model: 'model-x', apiKey: 'enc-', }); prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 }); authRequest.json.mockResolvedValue({ provider: 'custom', model: 'model-x', baseUrl: 'http://custom', }); const { POST } = await import('@/app/api/bookdate/config/route'); const response = await POST({} as any); const payload = await response.json(); expect(payload.success).toBe(true); expect(encryptionMock.encrypt).toHaveBeenCalledWith(''); }); it('deletes BookDate config', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ id: 'cfg-3' }); prismaMock.bookDateConfig.delete.mockResolvedValueOnce({}); prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 }); prismaMock.bookDateSwipe.deleteMany.mockResolvedValueOnce({ count: 1 }); const { DELETE } = await import('@/app/api/bookdate/config/route'); const response = await DELETE({} as any); const payload = await response.json(); expect(payload.success).toBe(true); }); it('returns 404 when deleting missing BookDate config', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); const { DELETE } = await import('@/app/api/bookdate/config/route'); const response = await DELETE({} as any); const payload = await response.json(); expect(response.status).toBe(404); expect(payload.error).toMatch(/Configuration not found/); }); it('returns BookDate preferences', async () => { prismaMock.user.findUnique.mockResolvedValueOnce({ bookDateLibraryScope: 'full', bookDateCustomPrompt: null, bookDateOnboardingComplete: true, }); configServiceMock.getBackendMode.mockResolvedValueOnce('plex'); const { GET } = await import('@/app/api/bookdate/preferences/route'); const response = await GET({} as any); const payload = await response.json(); expect(payload.libraryScope).toBe('full'); expect(payload.onboardingComplete).toBe(true); }); it('updates BookDate preferences', async () => { configServiceMock.getBackendMode.mockResolvedValueOnce('plex'); prismaMock.user.update.mockResolvedValueOnce({ bookDateLibraryScope: 'rated', bookDateCustomPrompt: 'Prompt', bookDateOnboardingComplete: true, }); authRequest.json.mockResolvedValue({ libraryScope: 'rated', customPrompt: 'Prompt', onboardingComplete: true }); const { PUT } = await import('@/app/api/bookdate/preferences/route'); const response = await PUT({} as any); const payload = await response.json(); expect(payload.success).toBe(true); expect(payload.libraryScope).toBe('rated'); }); it('returns cached recommendations without calling AI', async () => { prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([{ id: 'rec-1' }]); const { GET } = await import('@/app/api/bookdate/recommendations/route'); const response = await GET({} as any); const payload = await response.json(); expect(payload.source).toBe('cache'); expect(payload.recommendations).toHaveLength(1); }); it('returns error when recommendations are disabled', async () => { prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]); prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ isVerified: true, isEnabled: false, }); const { GET } = await import('@/app/api/bookdate/recommendations/route'); const response = await GET({} as any); const payload = await response.json(); expect(response.status).toBe(400); expect(payload.error).toMatch(/not configured/i); }); it('returns 404 when recommendation user is missing', async () => { prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]); prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ isVerified: true, isEnabled: true, provider: 'openai', model: 'gpt', apiKey: 'enc-key', }); prismaMock.user.findUnique.mockResolvedValueOnce(null); const { GET } = await import('@/app/api/bookdate/recommendations/route'); const response = await GET({} as any); const payload = await response.json(); expect(response.status).toBe(404); expect(payload.error).toMatch(/User not found/i); }); it('generates and stores recommendations when AI returns matches', async () => { prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]); prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ isVerified: true, isEnabled: true, provider: 'openai', model: 'gpt', apiKey: 'enc-key', baseUrl: null, }); prismaMock.user.findUnique.mockResolvedValueOnce({ bookDateLibraryScope: 'full', bookDateCustomPrompt: null, }); bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}'); bookdateHelpersMock.callAI.mockResolvedValueOnce({ recommendations: [{ title: 'Title', author: 'Author', reason: 'Because' }], }); bookdateHelpersMock.isAlreadySwiped.mockResolvedValue(false); bookdateHelpersMock.isInLibrary.mockResolvedValue(false); bookdateHelpersMock.matchToAudnexus.mockResolvedValueOnce({ asin: 'ASIN1', title: 'Title', author: 'Author', narrator: null, rating: null, description: null, coverUrl: null, }); bookdateHelpersMock.isAlreadyRequested.mockResolvedValue(false); (prismaMock.bookDateRecommendation as any).createMany = vi.fn().mockResolvedValueOnce({ count: 1 }); prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([{ id: 'rec-1' }]); const { GET } = await import('@/app/api/bookdate/recommendations/route'); const response = await GET({} as any); const payload = await response.json(); expect(payload.source).toBe('generated'); expect(prismaMock.bookDateRecommendation.createMany).toHaveBeenCalled(); expect(payload.recommendations).toHaveLength(1); }); it('returns error when generating recommendations without config', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); const { POST } = await import('@/app/api/bookdate/generate/route'); const response = await POST({} as any); const payload = await response.json(); expect(response.status).toBe(400); expect(payload.error).toMatch(/not configured/); }); it('returns 404 when no new recommendations can be matched', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ isVerified: true, isEnabled: true, provider: 'openai', model: 'gpt', apiKey: 'enc-key', baseUrl: null, }); prismaMock.user.findUnique.mockResolvedValueOnce({ bookDateLibraryScope: 'full', bookDateCustomPrompt: null, }); bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}'); bookdateHelpersMock.callAI.mockResolvedValueOnce({ recommendations: [{ title: 'Title', author: 'Author' }], }); bookdateHelpersMock.isAlreadySwiped.mockResolvedValue(false); bookdateHelpersMock.isInLibrary.mockResolvedValue(false); bookdateHelpersMock.matchToAudnexus.mockResolvedValueOnce(null); const { POST } = await import('@/app/api/bookdate/generate/route'); const response = await POST({} as any); const payload = await response.json(); expect(response.status).toBe(404); expect(payload.error).toMatch(/Could not find any new recommendations/i); }); it('stores generated recommendations from the AI', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ isVerified: true, isEnabled: true, provider: 'openai', model: 'gpt', apiKey: 'enc-key', baseUrl: null, }); prismaMock.user.findUnique.mockResolvedValueOnce({ bookDateLibraryScope: 'full', bookDateCustomPrompt: null, }); bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}'); bookdateHelpersMock.callAI.mockResolvedValueOnce({ recommendations: [{ title: 'Title', author: 'Author', reason: 'Because' }], }); bookdateHelpersMock.isAlreadySwiped.mockResolvedValue(false); bookdateHelpersMock.isInLibrary.mockResolvedValue(false); bookdateHelpersMock.matchToAudnexus.mockResolvedValueOnce({ asin: 'ASIN1', title: 'Title', author: 'Author', narrator: null, rating: null, description: null, coverUrl: null, }); bookdateHelpersMock.isAlreadyRequested.mockResolvedValue(false); (prismaMock.bookDateRecommendation as any).createMany = vi.fn().mockResolvedValueOnce({ count: 1 }); prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([{ id: 'rec-2' }]); const { POST } = await import('@/app/api/bookdate/generate/route'); const response = await POST({} as any); const payload = await response.json(); expect(payload.source).toBe('generated'); expect(prismaMock.bookDateRecommendation.createMany).toHaveBeenCalled(); expect(payload.recommendations).toHaveLength(1); }); it('records swipe and creates request on right swipe (admin auto-approves)', async () => { authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'right', markedAsKnown: false }); prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({ id: 'rec-1', userId: 'user-1', title: 'Title', author: 'Author', audnexusAsin: 'ASIN', } as any); prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any); prismaMock.audiobook.findFirst.mockResolvedValueOnce(null); prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' } as any); prismaMock.request.findFirst.mockResolvedValueOnce(null); prismaMock.user.findUnique.mockResolvedValueOnce({ id: 'user-1', role: 'admin', autoApproveRequests: null, plexUsername: 'testuser', } as any); prismaMock.request.create.mockResolvedValueOnce({ id: 'req-1', audiobook: { title: 'Title' }, user: { id: 'user-1', plexUsername: 'testuser' } } 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).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'pending', }), }) ); expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith( 'request_approved', 'req-1', 'Title', 'Author', 'testuser' ); expect(jobQueueMock.addSearchJob).toHaveBeenCalled(); }); it('creates request with awaiting_approval status when approval required (user with autoApproveRequests=false)', async () => { authRequest = { user: { id: 'user-2', role: 'user' }, json: vi.fn() }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); authRequest.json.mockResolvedValue({ recommendationId: 'rec-2', action: 'right', markedAsKnown: false }); prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({ id: 'rec-2', userId: 'user-2', title: 'Title 2', author: 'Author 2', audnexusAsin: 'ASIN2', } as any); prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any); prismaMock.audiobook.findFirst.mockResolvedValueOnce(null); prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-2', title: 'Title 2', author: 'Author 2', audibleAsin: 'ASIN2' } as any); prismaMock.request.findFirst.mockResolvedValueOnce(null); prismaMock.user.findUnique.mockResolvedValueOnce({ id: 'user-2', role: 'user', autoApproveRequests: false, plexUsername: 'testuser2', } as any); prismaMock.request.create.mockResolvedValueOnce({ id: 'req-2', audiobook: { title: 'Title 2' }, user: { id: 'user-2', plexUsername: 'testuser2' } } 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).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'awaiting_approval', }), }) ); expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith( 'request_pending_approval', 'req-2', 'Title 2', 'Author 2', 'testuser2' ); expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); }); it('creates request with pending status when user has autoApproveRequests=true', async () => { authRequest = { user: { id: 'user-3', role: 'user' }, json: vi.fn() }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); authRequest.json.mockResolvedValue({ recommendationId: 'rec-3', action: 'right', markedAsKnown: false }); prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({ id: 'rec-3', userId: 'user-3', title: 'Title 3', author: 'Author 3', audnexusAsin: 'ASIN3', } as any); prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any); prismaMock.audiobook.findFirst.mockResolvedValueOnce(null); prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-3', title: 'Title 3', author: 'Author 3', audibleAsin: 'ASIN3' } as any); prismaMock.request.findFirst.mockResolvedValueOnce(null); prismaMock.user.findUnique.mockResolvedValueOnce({ id: 'user-3', role: 'user', autoApproveRequests: true, plexUsername: 'testuser3', } as any); prismaMock.request.create.mockResolvedValueOnce({ id: 'req-3', audiobook: { title: 'Title 3' }, user: { id: 'user-3', plexUsername: 'testuser3' } } 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).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'pending', }), }) ); expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith( 'request_approved', 'req-3', 'Title 3', 'Author 3', 'testuser3' ); expect(jobQueueMock.addSearchJob).toHaveBeenCalled(); }); it('checks global setting when user autoApproveRequests is null', async () => { authRequest = { user: { id: 'user-4', role: 'user' }, json: vi.fn() }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); authRequest.json.mockResolvedValue({ recommendationId: 'rec-4', action: 'right', markedAsKnown: false }); prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({ id: 'rec-4', userId: 'user-4', title: 'Title 4', author: 'Author 4', audnexusAsin: 'ASIN4', } as any); prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any); prismaMock.audiobook.findFirst.mockResolvedValueOnce(null); prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-4', title: 'Title 4', author: 'Author 4', audibleAsin: 'ASIN4' } as any); prismaMock.request.findFirst.mockResolvedValueOnce(null); prismaMock.user.findUnique.mockResolvedValueOnce({ id: 'user-4', role: 'user', autoApproveRequests: null, plexUsername: 'testuser4', } as any); prismaMock.configuration.findUnique.mockResolvedValueOnce({ key: 'auto_approve_requests', value: 'false', } as any); prismaMock.request.create.mockResolvedValueOnce({ id: 'req-4', audiobook: { title: 'Title 4' }, user: { id: 'user-4', plexUsername: 'testuser4' } } 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).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'awaiting_approval', }), }) ); expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith( 'request_pending_approval', 'req-4', 'Title 4', 'Author 4', 'testuser4' ); expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); }); it('undoes last swipe', async () => { prismaMock.bookDateSwipe.findFirst.mockResolvedValueOnce({ id: 'swipe-1', recommendation: { id: 'rec-1', createdAt: new Date() }, }); prismaMock.bookDateRecommendation.findFirst.mockResolvedValueOnce(null); prismaMock.bookDateSwipe.delete.mockResolvedValueOnce({}); prismaMock.bookDateRecommendation.update.mockResolvedValueOnce({ id: 'rec-1' }); const { POST } = await import('@/app/api/bookdate/undo/route'); const response = await POST({} as any); const payload = await response.json(); expect(payload.success).toBe(true); }); it('clears all swipes as admin', async () => { prismaMock.bookDateSwipe.deleteMany.mockResolvedValueOnce({ count: 1 }); prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 }); const { DELETE } = await import('@/app/api/bookdate/swipes/route'); const response = await DELETE({} as any); const payload = await response.json(); expect(payload.success).toBe(true); }); it('tests BookDate connection without auth', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ data: [{ id: 'model-1' }] }), text: vi.fn().mockResolvedValue('ok'), }); vi.stubGlobal('fetch', fetchMock); const { POST } = await import('@/app/api/bookdate/test-connection/route'); const response = await POST({ headers: { get: () => null }, json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: '' }), } as any); const payload = await response.json(); expect(payload.success).toBe(true); expect(payload.models[0].id).toBe('model-1'); }); });