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:
235
backend/src/__tests__/unit/alertService.test.ts
Normal file
235
backend/src/__tests__/unit/alertService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user