/** * Component: Notification Service Tests * Documentation: documentation/backend/services/notifications.md */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPrismaMock } from '../helpers/prisma'; const prismaMock = createPrismaMock(); prismaMock.notificationBackend = { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), } as any; const encryptionMock = vi.hoisted(() => ({ encrypt: vi.fn((value: string) => `enc:${value}`), decrypt: vi.fn((value: string) => value.replace('enc:', '')), isEncryptedFormat: vi.fn((value: string) => typeof value === 'string' && value.startsWith('enc:')), })); const fetchMock = vi.hoisted(() => vi.fn()); vi.mock('@/lib/db', () => ({ prisma: prismaMock, })); vi.mock('@/lib/services/encryption.service', () => ({ getEncryptionService: () => encryptionMock, })); describe('getEventTitle', () => { it('returns type-specific title when requestType matches titleByRequestType', async () => { const { getEventTitle } = await import('@/lib/constants/notification-events'); expect(getEventTitle('request_available', 'audiobook')).toBe('Audiobook Available'); expect(getEventTitle('request_available', 'ebook')).toBe('Ebook Available'); }); it('returns default title when requestType is not provided', async () => { const { getEventTitle } = await import('@/lib/constants/notification-events'); expect(getEventTitle('request_available')).toBe('Request Available'); expect(getEventTitle('request_available', undefined)).toBe('Request Available'); }); it('returns default title when requestType does not match any entry', async () => { const { getEventTitle } = await import('@/lib/constants/notification-events'); expect(getEventTitle('request_available', 'podcast')).toBe('Request Available'); }); it('returns default title for events without titleByRequestType', async () => { const { getEventTitle } = await import('@/lib/constants/notification-events'); expect(getEventTitle('request_approved', 'audiobook')).toBe('Request Approved'); expect(getEventTitle('request_error')).toBe('Request Error'); expect(getEventTitle('request_pending_approval')).toBe('New Request Pending Approval'); }); }); describe('NotificationService', () => { beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal('fetch', fetchMock); }); describe('sendNotification', () => { it('sends notifications to all enabled backends subscribed to the event', async () => { prismaMock.notificationBackend.findMany.mockResolvedValue([ { id: '1', type: 'discord', name: 'Discord - Admins', config: { webhookUrl: 'https://discord.com/webhook1' }, 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_approved'], enabled: true, createdAt: new Date(), updatedAt: new Date(), }, ]); fetchMock.mockResolvedValue({ ok: true, json: async () => ({ success: true }), }); const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); await service.sendNotification({ event: 'request_approved', requestId: 'req-1', title: 'Test Book', author: 'Test Author', userName: 'Test User', timestamp: new Date(), }); expect(prismaMock.notificationBackend.findMany).toHaveBeenCalledWith({ where: { enabled: true, events: { array_contains: 'request_approved' }, }, }); // Should send to both backends expect(fetchMock).toHaveBeenCalledTimes(2); }); it('does not send if no backends are subscribed to the event', async () => { prismaMock.notificationBackend.findMany.mockResolvedValue([]); const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); await service.sendNotification({ event: 'request_approved', requestId: 'req-1', title: 'Test Book', author: 'Test Author', userName: 'Test User', timestamp: new Date(), }); expect(fetchMock).not.toHaveBeenCalled(); }); it('continues sending to other backends if one fails', async () => { prismaMock.notificationBackend.findMany.mockResolvedValue([ { id: '1', type: 'discord', name: 'Discord - Admins', config: { webhookUrl: 'https://discord.com/webhook1' }, events: ['request_approved'], enabled: true, createdAt: new Date(), updatedAt: new Date(), }, { id: '2', type: 'pushover', name: 'Pushover - Users', config: { userKey: 'user123', appToken: 'app456' }, events: ['request_approved'], enabled: true, createdAt: new Date(), updatedAt: new Date(), }, ]); // First backend fails, second succeeds fetchMock .mockRejectedValueOnce(new Error('Network error')) .mockResolvedValueOnce({ ok: true, json: async () => ({ success: true }), }); const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); await service.sendNotification({ event: 'request_approved', requestId: 'req-1', title: 'Test Book', author: 'Test Author', userName: 'Test User', timestamp: new Date(), }); // Should still attempt both expect(fetchMock).toHaveBeenCalledTimes(2); }); }); describe('sendToBackend', () => { it('routes to Discord provider and decrypts config', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => ({ success: true }), }); const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); await service.sendToBackend( 'discord', { webhookUrl: 'enc:https://discord.com/webhook', username: 'ReadMeABook' }, { event: 'request_approved', requestId: 'req-1', title: 'Test Book', author: 'Test Author', userName: 'Test User', timestamp: new Date('2024-01-01T00:00:00Z'), } ); expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:https://discord.com/webhook'); expect(fetchMock).toHaveBeenCalled(); const fetchCall = fetchMock.mock.calls[0]; // Decrypted URL should be used expect(fetchCall[0]).toBe('https://discord.com/webhook'); }); it('routes to Pushover provider and decrypts config', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => ({ status: 1 }), }); const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); await service.sendToBackend( 'pushover', { userKey: 'enc:user123', appToken: 'enc:app456', priority: 1 }, { event: 'request_approved', requestId: 'req-1', title: 'Test Book', author: 'Test Author', userName: 'Test User', timestamp: new Date(), } ); expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:user123'); expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:app456'); expect(fetchMock).toHaveBeenCalled(); }); it('throws error for unsupported backend type', async () => { const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); await expect( service.sendToBackend( 'email', {}, { event: 'request_approved', requestId: 'req-1', title: 'Test Book', author: 'Test Author', userName: 'Test User', timestamp: new Date(), } ) ).rejects.toThrow('Unsupported backend type: email'); }); }); describe('DiscordProvider', () => { it('sends Discord webhook with rich embed', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => ({ success: true }), }); const { DiscordProvider } = await import('@/lib/services/notification'); const provider = new DiscordProvider(); await provider.send( { webhookUrl: 'https://discord.com/webhook', username: 'ReadMeABook', }, { event: 'request_approved', requestId: 'req-1', title: 'Test Book', author: 'Test Author', userName: 'Test User', timestamp: new Date('2024-01-01T00:00:00Z'), } ); expect(fetchMock).toHaveBeenCalled(); const fetchCall = fetchMock.mock.calls[0]; const body = JSON.parse(fetchCall[1].body); expect(fetchCall[0]).toBe('https://discord.com/webhook'); expect(fetchCall[1].method).toBe('POST'); expect(fetchCall[1].headers['Content-Type']).toBe('application/json'); expect(body.username).toBe('ReadMeABook'); expect(body.embeds).toHaveLength(1); expect(body.embeds[0].title).toBe('✅ Request Approved'); expect(body.embeds[0].color).toBe(2278750); // Green for approved (0x22C55E) }); it('uses type-specific title for request_available with requestType', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => ({ success: true }), }); const { DiscordProvider } = await import('@/lib/services/notification'); const provider = new DiscordProvider(); // Test audiobook await provider.send( { webhookUrl: 'https://discord.com/webhook' }, { event: 'request_available', requestId: 'req-1', title: 'Test Book', author: 'Test Author', userName: 'Test User', requestType: 'audiobook', timestamp: new Date('2024-01-01T00:00:00Z'), } ); let body = JSON.parse(fetchMock.mock.calls[0][1].body); expect(body.embeds[0].title).toBe('\u{1F389} Audiobook Available'); // Test ebook fetchMock.mockClear(); await provider.send( { webhookUrl: 'https://discord.com/webhook' }, { event: 'request_available', requestId: 'req-2', title: 'Test Book 2', author: 'Test Author 2', userName: 'Test User', requestType: 'ebook', timestamp: new Date('2024-01-01T00:00:00Z'), } ); body = JSON.parse(fetchMock.mock.calls[0][1].body); expect(body.embeds[0].title).toBe('\u{1F389} Ebook Available'); // Test fallback (no requestType) fetchMock.mockClear(); await provider.send( { webhookUrl: 'https://discord.com/webhook' }, { event: 'request_available', requestId: 'req-3', title: 'Test Book 3', author: 'Test Author 3', userName: 'Test User', timestamp: new Date('2024-01-01T00:00:00Z'), } ); body = JSON.parse(fetchMock.mock.calls[0][1].body); expect(body.embeds[0].title).toBe('\u{1F389} Request Available'); }); it('uses default username if not provided', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => ({ success: true }), }); const { DiscordProvider } = await import('@/lib/services/notification'); const provider = new DiscordProvider(); await provider.send( { webhookUrl: 'https://discord.com/webhook', }, { event: 'request_approved', requestId: 'req-1', title: 'Test Book', author: 'Test Author', userName: 'Test User', timestamp: new Date(), } ); const body = JSON.parse(fetchMock.mock.calls[0][1].body); expect(body.username).toBe('ReadMeABook'); }); it('throws error if Discord API returns non-OK response', async () => { fetchMock.mockResolvedValue({ ok: false, status: 400, statusText: 'Bad Request', text: async () => 'Bad Request', }); const { DiscordProvider } = await import('@/lib/services/notification'); const provider = new DiscordProvider(); await expect( provider.send( { webhookUrl: 'https://discord.com/webhook' }, { event: 'request_approved', requestId: 'req-1', title: 'Test Book', author: 'Test Author', userName: 'Test User', timestamp: new Date(), } ) ).rejects.toThrow('Discord webhook failed: 400 Bad Request'); }); }); describe('PushoverProvider', () => { it('sends Pushover notification with correct payload', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => ({ status: 1 }), }); const { PushoverProvider } = await import('@/lib/services/notification'); const provider = new PushoverProvider(); await provider.send( { userKey: 'user123', appToken: 'app456', priority: 1, }, { event: 'request_approved', requestId: 'req-1', title: 'Test Book', author: 'Test Author', userName: 'Test User', timestamp: new Date(), } ); expect(fetchMock).toHaveBeenCalled(); const fetchCall = fetchMock.mock.calls[0]; expect(fetchCall[0]).toBe('https://api.pushover.net/1/messages.json'); expect(fetchCall[1].method).toBe('POST'); expect(fetchCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded'); const body = fetchCall[1].body; // Body should be URL-encoded string expect(typeof body).toBe('string'); expect(body).toContain('priority=1'); }); it('uses default priority if not provided', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => ({ status: 1 }), }); const { PushoverProvider } = await import('@/lib/services/notification'); const provider = new PushoverProvider(); await provider.send( { userKey: 'user123', appToken: 'app456', }, { event: 'request_approved', requestId: 'req-1', title: 'Test Book', author: 'Test Author', userName: 'Test User', timestamp: new Date(), } ); const body = fetchMock.mock.calls[0][1].body; expect(body.toString()).toContain('priority=0'); }); it('throws error if Pushover API returns non-OK response', async () => { fetchMock.mockResolvedValue({ ok: false, status: 400, text: async () => 'invalid user key', }); const { PushoverProvider } = await import('@/lib/services/notification'); const provider = new PushoverProvider(); await expect( provider.send( { userKey: 'user123', appToken: 'app456' }, { event: 'request_approved', requestId: 'req-1', title: 'Test Book', author: 'Test Author', userName: 'Test User', timestamp: new Date(), } ) ).rejects.toThrow(); }); }); describe('encryptConfig', () => { it('encrypts sensitive Discord config values', async () => { const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); const encrypted = service.encryptConfig('discord', { webhookUrl: 'https://discord.com/webhook', username: 'ReadMeABook', }); expect(encryptionMock.encrypt).toHaveBeenCalledWith('https://discord.com/webhook'); expect(encrypted.webhookUrl).toBe('enc:https://discord.com/webhook'); expect(encrypted.username).toBe('ReadMeABook'); // Not encrypted }); it('encrypts sensitive Pushover config values', async () => { const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); const encrypted = service.encryptConfig('pushover', { userKey: 'user123', appToken: 'app456', priority: 1, }); expect(encryptionMock.encrypt).toHaveBeenCalledWith('user123'); expect(encryptionMock.encrypt).toHaveBeenCalledWith('app456'); expect(encrypted.userKey).toBe('enc:user123'); expect(encrypted.appToken).toBe('enc:app456'); expect(encrypted.priority).toBe(1); // Not encrypted }); }); describe('getRegisteredProviderTypes', () => { it('returns all registered provider type keys', async () => { const { getRegisteredProviderTypes } = await import('@/lib/services/notification'); const types = getRegisteredProviderTypes(); expect(types).toContain('apprise'); expect(types).toContain('discord'); expect(types).toContain('ntfy'); expect(types).toContain('pushover'); expect(types).toHaveLength(4); }); }); describe('getAllProviderMetadata', () => { it('returns metadata for all registered providers', async () => { const { getAllProviderMetadata } = await import('@/lib/services/notification'); const metadata = getAllProviderMetadata(); expect(metadata).toHaveLength(4); const apprise = metadata.find((m) => m.type === 'apprise'); expect(apprise).toBeDefined(); expect(apprise!.displayName).toBe('Apprise'); expect(apprise!.iconLabel).toBe('A'); expect(apprise!.iconColor).toBe('bg-purple-500'); const discord = metadata.find((m) => m.type === 'discord'); expect(discord).toBeDefined(); expect(discord!.displayName).toBe('Discord'); expect(discord!.iconLabel).toBe('D'); expect(discord!.iconColor).toBe('bg-indigo-500'); expect(discord!.configFields.length).toBeGreaterThan(0); const ntfy = metadata.find((m) => m.type === 'ntfy'); expect(ntfy).toBeDefined(); expect(ntfy!.displayName).toBe('ntfy'); expect(ntfy!.iconLabel).toBe('N'); const pushover = metadata.find((m) => m.type === 'pushover'); expect(pushover).toBeDefined(); expect(pushover!.displayName).toBe('Pushover'); expect(pushover!.iconLabel).toBe('P'); }); it('includes config field definitions with correct properties', async () => { const { getAllProviderMetadata } = await import('@/lib/services/notification'); const metadata = getAllProviderMetadata(); const discord = metadata.find((m) => m.type === 'discord')!; const webhookField = discord.configFields.find((f) => f.name === 'webhookUrl'); expect(webhookField).toBeDefined(); expect(webhookField!.required).toBe(true); expect(webhookField!.type).toBe('text'); const pushover = metadata.find((m) => m.type === 'pushover')!; const priorityField = pushover.configFields.find((f) => f.name === 'priority'); expect(priorityField).toBeDefined(); expect(priorityField!.type).toBe('select'); expect(priorityField!.options).toBeDefined(); expect(priorityField!.options!.length).toBe(5); }); }); describe('reEncryptUnprotectedBackends', () => { it('re-encrypts plaintext sensitive fields stored due to isEncrypted bug', async () => { // Simulate a backend with a Telegram URL stored as plaintext (the bug) prismaMock.notificationBackend.findMany.mockResolvedValue([ { id: 'backend-1', type: 'apprise', name: 'Telegram via Apprise', config: { serverUrl: 'http://apprise:8000', urls: 'tgram://1234567890:PLPe1Hh-VhbRC3MoT5QngwkPHoMTD/-100181291455/', }, events: ['request_available'], enabled: true, createdAt: new Date(), updatedAt: new Date(), }, ]); prismaMock.notificationBackend.update.mockResolvedValue({} as any); // Mock isEncryptedFormat to return false for the plaintext URL encryptionMock.isEncryptedFormat.mockImplementation( (value: string) => typeof value === 'string' && value.startsWith('enc:') ); const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); const fixed = await service.reEncryptUnprotectedBackends(); expect(fixed).toBe(1); expect(encryptionMock.encrypt).toHaveBeenCalledWith( 'tgram://1234567890:PLPe1Hh-VhbRC3MoT5QngwkPHoMTD/-100181291455/' ); expect(prismaMock.notificationBackend.update).toHaveBeenCalledWith({ where: { id: 'backend-1' }, data: { config: { serverUrl: 'http://apprise:8000', urls: 'enc:tgram://1234567890:PLPe1Hh-VhbRC3MoT5QngwkPHoMTD/-100181291455/', }, }, }); }); it('skips backends with already-encrypted fields', async () => { prismaMock.notificationBackend.findMany.mockResolvedValue([ { id: 'backend-1', type: 'discord', name: 'Discord', config: { webhookUrl: 'enc:https://discord.com/webhook', username: 'Bot' }, events: ['request_available'], enabled: true, createdAt: new Date(), updatedAt: new Date(), }, ]); encryptionMock.isEncryptedFormat.mockImplementation( (value: string) => typeof value === 'string' && value.startsWith('enc:') ); const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); const fixed = await service.reEncryptUnprotectedBackends(); expect(fixed).toBe(0); expect(prismaMock.notificationBackend.update).not.toHaveBeenCalled(); }); it('returns 0 when no backends exist', async () => { prismaMock.notificationBackend.findMany.mockResolvedValue([]); const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); const fixed = await service.reEncryptUnprotectedBackends(); expect(fixed).toBe(0); }); }); describe('maskConfig', () => { it('masks sensitive Discord config values', async () => { const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); const masked = service.maskConfig('discord', { webhookUrl: 'https://discord.com/webhook/very/long/url', username: 'ReadMeABook', }); expect(masked.webhookUrl).toBe('••••••••'); expect(masked.username).toBe('ReadMeABook'); // Not masked }); it('masks sensitive Pushover config values', async () => { const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); const masked = service.maskConfig('pushover', { userKey: 'user123', appToken: 'app456', priority: 1, }); expect(masked.userKey).toBe('••••••••'); expect(masked.appToken).toBe('••••••••'); expect(masked.priority).toBe(1); // Not masked }); }); });