From 4b5afe21320188e6ea948ab4c894982d15b740a8 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 24 Feb 2026 14:30:16 -0500 Subject: [PATCH] test(02-03): add alertService unit tests (8 passing) - healthy probes trigger no alert logic - down probe creates alert_events row and sends email - degraded probe uses alert_type service_degraded - deduplication suppresses row creation and email within cooldown - recipient read from process.env.EMAIL_WEEKLY_RECIPIENT - missing recipient skips email but still creates alert row - email failure does not throw (non-throwing pipeline) - multiple probes processed independently - vi.mock factories use inline vi.fn() only (no TDZ hoisting errors) --- .../src/__tests__/unit/alertService.test.ts | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 backend/src/__tests__/unit/alertService.test.ts diff --git a/backend/src/__tests__/unit/alertService.test.ts b/backend/src/__tests__/unit/alertService.test.ts new file mode 100644 index 0000000..34e0024 --- /dev/null +++ b/backend/src/__tests__/unit/alertService.test.ts @@ -0,0 +1,235 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +// ============================================================================= +// Mocks — vi.mock is hoisted; factories must only use inline vi.fn() +// Per project decision: vi.mock() factories must not reference outer variables +// (Vitest hoisting TDZ error prevention — see 01-02 decision log) +// ============================================================================= + +vi.mock('../../models/AlertEventModel', () => ({ + AlertEventModel: { + findRecentByService: vi.fn(), + create: vi.fn(), + }, +})); + +vi.mock('nodemailer', () => ({ + default: { + createTransport: vi.fn().mockReturnValue({ + sendMail: vi.fn().mockResolvedValue({}), + }), + }, +})); + +vi.mock('../../utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// ============================================================================= +// Imports (after mocks) +// ============================================================================= + +import { alertService } from '../../services/alertService'; +import { AlertEventModel } from '../../models/AlertEventModel'; +import { logger } from '../../utils/logger'; +import nodemailer from 'nodemailer'; +import type { ProbeResult } from '../../services/healthProbeService'; + +// ============================================================================= +// Fixtures +// ============================================================================= + +const healthyProbe: ProbeResult = { + service_name: 'supabase', + status: 'healthy', + latency_ms: 50, +}; + +const downProbe: ProbeResult = { + service_name: 'document_ai', + status: 'down', + latency_ms: 0, + error_message: 'Connection refused', +}; + +const degradedProbe: ProbeResult = { + service_name: 'llm_api', + status: 'degraded', + latency_ms: 6000, +}; + +const mockAlertRow = { + id: 'uuid-alert-1', + service_name: 'document_ai', + alert_type: 'service_down' as const, + status: 'active' as const, + message: 'Connection refused', + details: null, + created_at: new Date().toISOString(), + acknowledged_at: null, + resolved_at: null, +}; + +// ============================================================================= +// Helpers — access mocked sendMail via the mocked createTransport return value +// ============================================================================= + +function getMockSendMail(): ReturnType { + const mockTransporter = vi.mocked(nodemailer.createTransport).mock.results[0]?.value as + | { sendMail: ReturnType } + | undefined; + return mockTransporter?.sendMail ?? vi.fn(); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('alertService.evaluateAndAlert', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env['EMAIL_WEEKLY_RECIPIENT'] = 'admin@test.com'; + // Reset nodemailer mock — clearAllMocks wipes mock return values + vi.mocked(nodemailer.createTransport).mockReturnValue({ + sendMail: vi.fn().mockResolvedValue({}), + } as ReturnType); + // Default: no recent alert (allow sending) + vi.mocked(AlertEventModel.findRecentByService).mockResolvedValue(null); + vi.mocked(AlertEventModel.create).mockResolvedValue(mockAlertRow); + }); + + // --------------------------------------------------------------------------- + // 1. Healthy probes — no alerts sent + // --------------------------------------------------------------------------- + test('healthy probes do not trigger any alert logic', async () => { + await alertService.evaluateAndAlert([healthyProbe]); + + expect(AlertEventModel.findRecentByService).not.toHaveBeenCalled(); + expect(AlertEventModel.create).not.toHaveBeenCalled(); + }); + + // --------------------------------------------------------------------------- + // 2. Down probe — creates alert row and sends email + // --------------------------------------------------------------------------- + test('down probe creates an alert_events row and sends email', async () => { + await alertService.evaluateAndAlert([downProbe]); + + expect(AlertEventModel.findRecentByService).toHaveBeenCalledWith( + 'document_ai', + 'service_down', + expect.any(Number) + ); + expect(AlertEventModel.create).toHaveBeenCalledWith( + expect.objectContaining({ + service_name: 'document_ai', + alert_type: 'service_down', + }) + ); + const sendMail = getMockSendMail(); + expect(sendMail).toHaveBeenCalledTimes(1); + }); + + // --------------------------------------------------------------------------- + // 3. Degraded probe — creates alert with type 'service_degraded' + // --------------------------------------------------------------------------- + test('degraded probe creates alert with alert_type service_degraded', async () => { + await alertService.evaluateAndAlert([degradedProbe]); + + expect(AlertEventModel.create).toHaveBeenCalledWith( + expect.objectContaining({ + service_name: 'llm_api', + alert_type: 'service_degraded', + }) + ); + }); + + // --------------------------------------------------------------------------- + // 4. Deduplication — suppresses within cooldown + // --------------------------------------------------------------------------- + test('suppresses alert when recent alert exists within cooldown', async () => { + vi.mocked(AlertEventModel.findRecentByService).mockResolvedValue(mockAlertRow); + + await alertService.evaluateAndAlert([downProbe]); + + expect(AlertEventModel.create).not.toHaveBeenCalled(); + const sendMail = getMockSendMail(); + expect(sendMail).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('suppress'), + expect.any(Object) + ); + }); + + // --------------------------------------------------------------------------- + // 5. Recipient from env — reads process.env.EMAIL_WEEKLY_RECIPIENT + // --------------------------------------------------------------------------- + test('sends email to address from process.env.EMAIL_WEEKLY_RECIPIENT', async () => { + process.env['EMAIL_WEEKLY_RECIPIENT'] = 'test@example.com'; + + await alertService.evaluateAndAlert([downProbe]); + + const sendMail = getMockSendMail(); + expect(sendMail).toHaveBeenCalledWith( + expect.objectContaining({ to: 'test@example.com' }) + ); + }); + + // --------------------------------------------------------------------------- + // 6. No recipient configured — skips email but still creates alert row + // --------------------------------------------------------------------------- + test('skips email but still creates alert row when no recipient configured', async () => { + delete process.env['EMAIL_WEEKLY_RECIPIENT']; + + await alertService.evaluateAndAlert([downProbe]); + + expect(AlertEventModel.create).toHaveBeenCalledTimes(1); + const sendMail = getMockSendMail(); + expect(sendMail).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('EMAIL_WEEKLY_RECIPIENT'), + expect.any(Object) + ); + }); + + // --------------------------------------------------------------------------- + // 7. Email failure — does not throw + // --------------------------------------------------------------------------- + test('does not throw when email sending fails', async () => { + const failSendMail = vi.fn().mockRejectedValue(new Error('SMTP connection refused')); + vi.mocked(nodemailer.createTransport).mockReturnValue({ + sendMail: failSendMail, + } as ReturnType); + + await expect(alertService.evaluateAndAlert([downProbe])).resolves.not.toThrow(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('failed to send alert email'), + expect.any(Object) + ); + }); + + // --------------------------------------------------------------------------- + // 8. Multiple probes — processes each independently + // --------------------------------------------------------------------------- + test('processes down and degraded probes independently, skips healthy', async () => { + await alertService.evaluateAndAlert([downProbe, degradedProbe, healthyProbe]); + + // Called once for down, once for degraded — not for healthy + expect(AlertEventModel.findRecentByService).toHaveBeenCalledTimes(2); + expect(AlertEventModel.findRecentByService).toHaveBeenCalledWith( + 'document_ai', + 'service_down', + expect.any(Number) + ); + expect(AlertEventModel.findRecentByService).toHaveBeenCalledWith( + 'llm_api', + 'service_degraded', + expect.any(Number) + ); + expect(AlertEventModel.create).toHaveBeenCalledTimes(2); + }); +});