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)
This commit is contained in:
admin
2026-02-24 14:30:16 -05:00
parent 91f609cf92
commit 4b5afe2132

View File

@@ -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<typeof vi.fn> {
const mockTransporter = vi.mocked(nodemailer.createTransport).mock.results[0]?.value as
| { sendMail: ReturnType<typeof vi.fn> }
| 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<typeof nodemailer.createTransport>);
// 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<typeof nodemailer.createTransport>);
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);
});
});