/** * Component: Notification Trigger Integration Tests * Documentation: documentation/backend/services/notifications.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 jobQueueMock = vi.hoisted(() => ({ addNotificationJob: vi.fn(() => Promise.resolve('job-1')), addSearchJob: vi.fn(() => Promise.resolve('job-2')), })); const findPlexMatchMock = vi.hoisted(() => vi.fn()); const audibleServiceMock = vi.hoisted(() => ({ getAudiobookDetails: vi.fn(), })); vi.mock('@/lib/db', () => ({ prisma: prismaMock, })); vi.mock('@/lib/middleware/auth', () => ({ requireAuth: requireAuthMock, requireAdmin: requireAdminMock, })); vi.mock('@/lib/services/job-queue.service', () => ({ getJobQueueService: () => jobQueueMock, })); vi.mock('@/lib/utils/audiobook-matcher', () => ({ findPlexMatch: findPlexMatchMock, })); vi.mock('@/lib/integrations/audible.service', () => ({ getAudibleService: () => audibleServiceMock, })); describe('Notification Triggers - Integration Tests', () => { beforeEach(() => { vi.clearAllMocks(); authRequest = { user: { id: 'user-1', role: 'user' }, json: vi.fn(), nextUrl: { searchParams: { get: vi.fn() } } }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); findPlexMatchMock.mockResolvedValue(null); audibleServiceMock.getAudiobookDetails.mockResolvedValue({ releaseDate: '2024-01-01', }); }); describe('POST /api/requests - Request Pending Approval', () => { it('sends pending approval notification when user needs approval', async () => { const requestBody = { audiobook: { asin: 'B001', title: 'Test Book', author: 'Test Author', }, }; authRequest.json.mockResolvedValue(requestBody); authRequest.nextUrl.searchParams.get.mockReturnValue(null); prismaMock.request.findFirst.mockResolvedValue(null); // No existing active request prismaMock.audiobook.findFirst.mockResolvedValue(null); // No existing audiobook prismaMock.audiobook.create.mockResolvedValue({ id: 'audiobook-1', audibleAsin: 'B001', title: 'Test Book', author: 'Test Author', status: 'requested', createdAt: new Date(), updatedAt: new Date(), }); // User needs approval (autoApproveRequests = false) prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'user', autoApproveRequests: false, plexUsername: 'testuser', }); prismaMock.request.create.mockResolvedValue({ id: 'req-1', userId: 'user-1', audiobookId: 'audiobook-1', status: 'awaiting_approval', progress: 0, createdAt: new Date(), updatedAt: new Date(), audiobook: { id: 'audiobook-1', title: 'Test Book', author: 'Test Author', }, user: { id: 'user-1', plexUsername: 'testuser', }, }); const { POST } = await import('@/app/api/requests/route'); const response = await POST(authRequest as any); const payload = await response.json(); expect(payload.success).toBe(true); expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith( 'request_pending_approval', 'req-1', 'Test Book', 'Test Author', 'testuser' ); expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); // No search when awaiting approval }); }); describe('POST /api/requests - Request Approved (Auto-Approval)', () => { it('sends approved notification when user auto-approved with automatic search', async () => { const requestBody = { audiobook: { asin: 'B001', title: 'Test Book', author: 'Test Author', }, }; authRequest.json.mockResolvedValue(requestBody); authRequest.nextUrl.searchParams.get.mockReturnValue(null); // skipAutoSearch = false prismaMock.request.findFirst.mockResolvedValue(null); prismaMock.audiobook.findFirst.mockResolvedValue(null); prismaMock.audiobook.create.mockResolvedValue({ id: 'audiobook-1', audibleAsin: 'B001', title: 'Test Book', author: 'Test Author', status: 'requested', createdAt: new Date(), updatedAt: new Date(), }); // User has auto-approve enabled prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'user', autoApproveRequests: true, plexUsername: 'testuser', }); prismaMock.request.create.mockResolvedValue({ id: 'req-1', userId: 'user-1', audiobookId: 'audiobook-1', status: 'pending', progress: 0, createdAt: new Date(), updatedAt: new Date(), audiobook: { id: 'audiobook-1', title: 'Test Book', author: 'Test Author', }, user: { id: 'user-1', plexUsername: 'testuser', }, }); const { POST } = await import('@/app/api/requests/route'); await POST(authRequest as any); expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith( 'request_approved', 'req-1', 'Test Book', 'Test Author', 'testuser' ); expect(jobQueueMock.addSearchJob).toHaveBeenCalled(); // Search triggered }); it('sends approved notification when user auto-approved with interactive search', async () => { const requestBody = { audiobook: { asin: 'B001', title: 'Test Book', author: 'Test Author', }, }; authRequest.json.mockResolvedValue(requestBody); authRequest.nextUrl.searchParams.get.mockReturnValue('true'); // skipAutoSearch = true prismaMock.request.findFirst.mockResolvedValue(null); prismaMock.audiobook.findFirst.mockResolvedValue(null); prismaMock.audiobook.create.mockResolvedValue({ id: 'audiobook-1', audibleAsin: 'B001', title: 'Test Book', author: 'Test Author', status: 'requested', createdAt: new Date(), updatedAt: new Date(), }); // User has auto-approve enabled prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'user', autoApproveRequests: true, plexUsername: 'testuser', }); prismaMock.request.create.mockResolvedValue({ id: 'req-1', userId: 'user-1', audiobookId: 'audiobook-1', status: 'awaiting_search', progress: 0, createdAt: new Date(), updatedAt: new Date(), audiobook: { id: 'audiobook-1', title: 'Test Book', author: 'Test Author', }, user: { id: 'user-1', plexUsername: 'testuser', }, }); const { POST } = await import('@/app/api/requests/route'); await POST(authRequest as any); // Should still send approved notification even with interactive search expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith( 'request_approved', 'req-1', 'Test Book', 'Test Author', 'testuser' ); expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); // No automatic search }); }); describe('POST /api/admin/requests/[id]/approve - Manual Approval', () => { it('sends approved notification when admin manually approves request', async () => { const adminRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn().mockResolvedValue({}), }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(adminRequest)); prismaMock.request.findUnique.mockResolvedValue({ id: 'req-1', userId: 'user-1', audiobookId: 'audiobook-1', status: 'awaiting_approval', progress: 0, audiobook: { id: 'audiobook-1', audibleAsin: 'B001', title: 'Test Book', author: 'Test Author', }, user: { id: 'user-1', plexUsername: 'testuser', }, createdAt: new Date(), updatedAt: new Date(), }); prismaMock.request.update.mockResolvedValue({ id: 'req-1', userId: 'user-1', audiobookId: 'audiobook-1', status: 'pending', progress: 0, audiobook: { id: 'audiobook-1', title: 'Test Book', author: 'Test Author', }, user: { id: 'user-1', plexUsername: 'testuser', }, createdAt: new Date(), updatedAt: new Date(), }); const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); const approveRequest = { json: vi.fn().mockResolvedValue({ action: 'approve' }), }; await POST(approveRequest as any, { params: Promise.resolve({ id: 'req-1' }) }); expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith( 'request_approved', 'req-1', 'Test Book', 'Test Author', 'testuser' ); }); }); describe('Interactive Search - Approval Bypass Prevention', () => { it('blocks interactive search when request awaiting approval', async () => { authRequest.json.mockResolvedValue({}); prismaMock.request.findUnique.mockResolvedValue({ id: 'req-1', userId: 'user-1', audiobookId: 'audiobook-1', status: 'awaiting_approval', // Awaiting approval progress: 0, audiobook: { id: 'audiobook-1', title: 'Test Book', author: 'Test Author', }, createdAt: new Date(), updatedAt: new Date(), }); const { POST } = await import('@/app/api/requests/[id]/interactive-search/route'); const response = await POST(authRequest as any, { params: Promise.resolve({ id: 'req-1' }) }); const payload = await response.json(); expect(response.status).toBe(403); expect(payload.error).toBe('AwaitingApproval'); }); it('blocks torrent selection when request awaiting approval', async () => { authRequest.json.mockResolvedValue({ torrent: { title: 'Test Torrent', downloadUrl: 'magnet:?xt=...', }, }); prismaMock.request.findUnique.mockResolvedValue({ id: 'req-1', userId: 'user-1', audiobookId: 'audiobook-1', status: 'awaiting_approval', // Awaiting approval progress: 0, audiobook: { id: 'audiobook-1', title: 'Test Book', author: 'Test Author', }, createdAt: new Date(), updatedAt: new Date(), }); const { POST } = await import('@/app/api/requests/[id]/select-torrent/route'); const response = await POST(authRequest as any, { params: Promise.resolve({ id: 'req-1' }) }); const payload = await response.json(); expect(response.status).toBe(403); expect(payload.error).toBe('AwaitingApproval'); }); }); });