/** * Unit tests for SlackNotifier, WebhookNotifier, and NotificationService. * HTTP calls are mocked via jest.spyOn on global fetch. */ import { SlackNotifier } from '../../src/server/notifications/SlackNotifier'; import { WebhookNotifier } from '../../src/server/notifications/WebhookNotifier'; import { NotificationService } from '../../src/server/notifications/NotificationService'; import { IAnomaly } from '../../src/core/interfaces'; function makeAnomaly(overrides: Partial = {}): IAnomaly { return { id: 'anom_1', type: 'http_error', severity: 'high', observationId: 'obs_1', actionTrace: [], description: 'Test anomaly', evidence: {}, timestamp: 1000000, ...overrides, }; } function mockFetch(ok: boolean, text = 'ok') { return jest.spyOn(global, 'fetch').mockResolvedValue({ ok, status: ok ? 200 : 500, text: () => Promise.resolve(text), } as Response); } afterEach(() => { jest.restoreAllMocks(); }); // ─── SlackNotifier ──────────────────────────────────────────────────────────── describe('SlackNotifier', () => { it('sends a POST to the Slack webhook URL', async () => { const spy = mockFetch(true); const notifier = new SlackNotifier('https://hooks.slack.com/test'); await notifier.send(makeAnomaly(), 'sess_1', 'http://app.com'); expect(spy).toHaveBeenCalledTimes(1); const [url, opts] = spy.mock.calls[0]!; expect(url).toBe('https://hooks.slack.com/test'); expect((opts as RequestInit).method).toBe('POST'); const body = JSON.parse((opts as RequestInit).body as string); expect(body.blocks).toBeDefined(); }); it('throws when Slack returns non-200', async () => { mockFetch(false, 'channel_not_found'); const notifier = new SlackNotifier('https://hooks.slack.com/test'); await expect(notifier.send(makeAnomaly(), 'sess_1', 'http://app.com')).rejects.toThrow('500'); }); }); // ─── WebhookNotifier ───────────────────────────────────────────────────────── describe('WebhookNotifier', () => { it('sends a POST with anomaly JSON to webhook URL', async () => { const spy = mockFetch(true); const notifier = new WebhookNotifier('https://myapp.com/webhooks/abe'); await notifier.send(makeAnomaly()); expect(spy).toHaveBeenCalledTimes(1); const [url, opts] = spy.mock.calls[0]!; expect(url).toBe('https://myapp.com/webhooks/abe'); expect((opts as RequestInit).method).toBe('POST'); const body = JSON.parse((opts as RequestInit).body as string); expect(body.id).toBe('anom_1'); }); it('throws when webhook returns non-200', async () => { mockFetch(false, 'bad gateway'); const notifier = new WebhookNotifier('https://myapp.com/webhooks/abe'); await expect(notifier.send(makeAnomaly())).rejects.toThrow('500'); }); }); // ─── NotificationService ───────────────────────────────────────────────────── describe('NotificationService', () => { it('skips notifications below minSeverity', async () => { const spy = mockFetch(true); const service = new NotificationService({ webhookUrl: 'https://myapp.com/hooks', minSeverity: 'high', }); await service.notify(makeAnomaly({ severity: 'low' }), 'sess_1', 'http://app.com'); expect(spy).not.toHaveBeenCalled(); }); it('sends notification at or above minSeverity', async () => { const spy = mockFetch(true); const service = new NotificationService({ webhookUrl: 'https://myapp.com/hooks', minSeverity: 'high', }); await service.notify(makeAnomaly({ severity: 'high' }), 'sess_1', 'http://app.com'); expect(spy).toHaveBeenCalledTimes(1); }); it('calls persister with success record on successful send', async () => { mockFetch(true); const persisted: unknown[] = []; const service = new NotificationService({ webhookUrl: 'https://myapp.com/hooks', minSeverity: 'low', persister: (r) => persisted.push(r), }); await service.notify(makeAnomaly({ severity: 'medium' }), 'sess_1', 'http://app.com'); expect(persisted).toHaveLength(1); expect((persisted[0] as { status: string }).status).toBe('success'); }); it('calls persister with failed record on send failure', async () => { mockFetch(false, 'error'); jest.useFakeTimers(); const persisted: unknown[] = []; const service = new NotificationService({ webhookUrl: 'https://myapp.com/hooks', minSeverity: 'low', persister: (r) => persisted.push(r), }); await service.notify(makeAnomaly({ severity: 'medium' }), 'sess_1', 'http://app.com'); expect(persisted).toHaveLength(1); expect((persisted[0] as { status: string }).status).toBe('failed'); jest.useRealTimers(); }); });