/** * Component: Admin Notifications API Route 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(); prismaMock.notificationBackend = { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), } as any; const requireAuthMock = vi.hoisted(() => vi.fn()); const requireAdminMock = vi.hoisted(() => vi.fn()); const notificationServiceMock = vi.hoisted(() => ({ encryptConfig: vi.fn((type: string, config: any) => ({ ...config, encrypted: true })), maskConfig: vi.fn((type: string, config: any) => ({ ...config, masked: true })), sendToBackend: vi.fn(), sendNotification: vi.fn(), })); vi.mock('@/lib/db', () => ({ prisma: prismaMock, })); vi.mock('@/lib/middleware/auth', () => ({ requireAuth: requireAuthMock, requireAdmin: requireAdminMock, })); vi.mock('@/lib/services/notification.service', () => ({ getNotificationService: () => notificationServiceMock, })); describe('Admin notifications routes', () => { beforeEach(() => { vi.clearAllMocks(); authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); }); describe('GET /api/admin/notifications', () => { it('returns all notification backends with masked config', async () => { const backends = [ { id: '1', type: 'discord', name: 'Discord - Admins', config: { webhookUrl: 'https://discord.com/webhook', username: 'Bot' }, events: ['request_approved', 'request_available'], enabled: true, createdAt: new Date(), updatedAt: new Date(), }, { id: '2', type: 'pushover', name: 'Pushover - Users', config: { userKey: 'user123', appToken: 'app456' }, events: ['request_available'], enabled: true, createdAt: new Date(), updatedAt: new Date(), }, ]; prismaMock.notificationBackend.findMany.mockResolvedValue(backends); const { GET } = await import('@/app/api/admin/notifications/route'); const response = await GET({} as any); const payload = await response.json(); expect(payload.success).toBe(true); expect(payload.backends).toHaveLength(2); expect(notificationServiceMock.maskConfig).toHaveBeenCalledTimes(2); }); it('returns empty array if no backends configured', async () => { prismaMock.notificationBackend.findMany.mockResolvedValue([]); const { GET } = await import('@/app/api/admin/notifications/route'); const response = await GET({} as any); const payload = await response.json(); expect(payload.success).toBe(true); expect(payload.backends).toHaveLength(0); }); }); describe('POST /api/admin/notifications', () => { it('creates new notification backend with encrypted config', async () => { const newBackend = { type: 'discord', name: 'Discord - Admins', config: { webhookUrl: 'https://discord.com/webhook' }, events: ['request_approved'], enabled: true, }; authRequest.json.mockResolvedValue(newBackend); prismaMock.notificationBackend.create.mockResolvedValue({ id: '1', ...newBackend, config: { webhookUrl: 'https://discord.com/webhook', encrypted: true }, createdAt: new Date(), updatedAt: new Date(), }); const { POST } = await import('@/app/api/admin/notifications/route'); const response = await POST({ json: authRequest.json } as any); const payload = await response.json(); expect(payload.success).toBe(true); expect(notificationServiceMock.encryptConfig).toHaveBeenCalledWith('discord', newBackend.config); expect(prismaMock.notificationBackend.create).toHaveBeenCalled(); }); it('validates required fields', async () => { authRequest.json.mockResolvedValue({ type: 'discord', // Missing name, config, events }); const { POST } = await import('@/app/api/admin/notifications/route'); const response = await POST({ json: authRequest.json } as any); expect(response.status).toBe(400); const payload = await response.json(); expect(payload.error).toBe('ValidationError'); }); it('validates at least one event is selected', async () => { authRequest.json.mockResolvedValue({ type: 'discord', name: 'Discord - Admins', config: { webhookUrl: 'https://discord.com/webhook' }, events: [], // Empty events array enabled: true, }); const { POST } = await import('@/app/api/admin/notifications/route'); const response = await POST({ json: authRequest.json } as any); expect(response.status).toBe(400); const payload = await response.json(); // The error field is just "ValidationError" but details are in the error string expect(payload.error).toBeDefined(); expect(typeof payload.error).toBe('string'); }); it('validates Discord config has webhookUrl', async () => { authRequest.json.mockResolvedValue({ type: 'discord', name: 'Discord - Admins', config: { username: 'Bot' }, // Missing webhookUrl events: ['request_approved'], enabled: true, }); const { POST } = await import('@/app/api/admin/notifications/route'); const response = await POST({ json: authRequest.json } as any); // Should return 500 because validation happens after Prisma mock fails expect(response.status).toBeGreaterThanOrEqual(400); const payload = await response.json(); expect(payload.error).toBeDefined(); }); it('validates Pushover config has userKey and appToken', async () => { authRequest.json.mockResolvedValue({ type: 'pushover', name: 'Pushover - Users', config: { userKey: 'user123' }, // Missing appToken events: ['request_approved'], enabled: true, }); const { POST } = await import('@/app/api/admin/notifications/route'); const response = await POST({ json: authRequest.json } as any); // Should return error (400 or 500) expect(response.status).toBeGreaterThanOrEqual(400); const payload = await response.json(); expect(payload.error).toBeDefined(); }); }); describe('GET /api/admin/notifications/[id]', () => { it('returns notification backend with masked config', async () => { const backend = { id: '1', type: 'discord', name: 'Discord - Admins', config: { webhookUrl: 'https://discord.com/webhook' }, events: ['request_approved'], enabled: true, createdAt: new Date(), updatedAt: new Date(), }; prismaMock.notificationBackend.findUnique.mockResolvedValue(backend); const { GET } = await import('@/app/api/admin/notifications/[id]/route'); const response = await GET({} as any, { params: Promise.resolve({ id: '1' }) }); const payload = await response.json(); expect(payload.success).toBe(true); expect(payload.backend.id).toBe('1'); expect(notificationServiceMock.maskConfig).toHaveBeenCalled(); }); it('returns 404 if backend not found', async () => { prismaMock.notificationBackend.findUnique.mockResolvedValue(null); const { GET } = await import('@/app/api/admin/notifications/[id]/route'); const response = await GET({} as any, { params: Promise.resolve({ id: 'nonexistent' }) }); expect(response.status).toBe(404); const payload = await response.json(); expect(payload.error).toBe('NotFound'); }); }); describe('PUT /api/admin/notifications/[id]', () => { it('updates notification backend', async () => { const existingBackend = { id: '1', type: 'discord', name: 'Discord - Admins', config: { webhookUrl: 'enc:https://discord.com/old' }, events: ['request_approved'], enabled: true, createdAt: new Date(), updatedAt: new Date(), }; const updates = { name: 'Discord - Updated', events: ['request_approved', 'request_available'], }; prismaMock.notificationBackend.findUnique.mockResolvedValue(existingBackend); authRequest.json.mockResolvedValue(updates); prismaMock.notificationBackend.update.mockResolvedValue({ ...existingBackend, ...updates, }); const { PUT } = await import('@/app/api/admin/notifications/[id]/route'); const response = await PUT( { json: authRequest.json } as any, { params: Promise.resolve({ id: '1' }) } ); const payload = await response.json(); expect(payload.success).toBe(true); expect(prismaMock.notificationBackend.update).toHaveBeenCalled(); }); it('preserves masked config values on update', async () => { const existingBackend = { id: '1', type: 'discord', name: 'Discord - Admins', config: { webhookUrl: 'enc:https://discord.com/webhook' }, events: ['request_approved'], enabled: true, createdAt: new Date(), updatedAt: new Date(), }; const updates = { config: { webhookUrl: '••••••••', username: 'NewBot' }, // Masked webhook }; prismaMock.notificationBackend.findUnique.mockResolvedValue(existingBackend); authRequest.json.mockResolvedValue(updates); prismaMock.notificationBackend.update.mockResolvedValue(existingBackend); const { PUT } = await import('@/app/api/admin/notifications/[id]/route'); const response = await PUT( { json: authRequest.json } as any, { params: Promise.resolve({ id: '1' }) } ); expect(response.status).toBe(200); // Should preserve existing encrypted webhook and add new username expect(prismaMock.notificationBackend.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ config: expect.objectContaining({ webhookUrl: 'enc:https://discord.com/webhook', // Original encrypted value username: 'NewBot', }), }), }) ); }); it('returns 404 if backend not found', async () => { prismaMock.notificationBackend.findUnique.mockResolvedValue(null); authRequest.json.mockResolvedValue({ name: 'Updated' }); const { PUT } = await import('@/app/api/admin/notifications/[id]/route'); const response = await PUT( { json: authRequest.json } as any, { params: Promise.resolve({ id: 'nonexistent' }) } ); expect(response.status).toBe(404); }); }); describe('DELETE /api/admin/notifications/[id]', () => { it('deletes notification backend', async () => { const backend = { id: '1', type: 'discord', name: 'Discord - Admins', config: {}, events: [], enabled: true, createdAt: new Date(), updatedAt: new Date(), }; // Mock findUnique to return the backend (so it passes the existence check) prismaMock.notificationBackend.findUnique.mockResolvedValue(backend); // Mock delete to simulate successful deletion prismaMock.notificationBackend.delete.mockResolvedValue(backend); const { DELETE } = await import('@/app/api/admin/notifications/[id]/route'); const response = await DELETE({} as any, { params: Promise.resolve({ id: '1' }) }); const payload = await response.json(); expect(payload.success).toBe(true); expect(prismaMock.notificationBackend.delete).toHaveBeenCalledWith({ where: { id: '1' }, }); }); it('returns 404 if backend not found', async () => { prismaMock.notificationBackend.delete.mockRejectedValue(new Error('Record not found')); const { DELETE } = await import('@/app/api/admin/notifications/[id]/route'); const response = await DELETE({} as any, { params: Promise.resolve({ id: 'nonexistent' }) }); expect(response.status).toBe(404); }); }); });