test(01-02): add AlertEventModel unit tests
- Tests cover create (valid, default status active, explicit status, with details JSONB) - Input validation (empty name, invalid alert_type, invalid status) - Supabase error handling (throws descriptive message) - findActive (all active, filtered by service, empty array) - acknowledge (sets status+timestamp, throws on not found via PGRST116) - resolve (sets status+timestamp, throws on not found) - findRecentByService (found within window, null when absent — deduplication use case) - deleteOlderThan (cutoff date, returns count) - All 41 tests pass (14 HealthCheck + 19 AlertEvent + 8 existing)
This commit is contained in:
410
backend/src/__tests__/models/AlertEventModel.test.ts
Normal file
410
backend/src/__tests__/models/AlertEventModel.test.ts
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Mocks — vi.mock is hoisted; factory must not reference outer variables
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
vi.mock('../../utils/logger', () => ({
|
||||||
|
logger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../config/supabase', () => ({
|
||||||
|
getSupabaseServiceClient: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Import model and mocked modules AFTER vi.mock declarations
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { AlertEventModel, AlertEvent } from '../../models/AlertEventModel';
|
||||||
|
import { getSupabaseServiceClient } from '../../config/supabase';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
const mockGetSupabaseServiceClient = vi.mocked(getSupabaseServiceClient);
|
||||||
|
const mockLogger = vi.mocked(logger);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function makeAlertEventRecord(overrides: Partial<AlertEvent> = {}): AlertEvent {
|
||||||
|
return {
|
||||||
|
id: 'alert-uuid-123',
|
||||||
|
service_name: 'claude_ai',
|
||||||
|
alert_type: 'service_down',
|
||||||
|
status: 'active',
|
||||||
|
message: 'API returned 503',
|
||||||
|
details: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
acknowledged_at: null,
|
||||||
|
resolved_at: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a chainable Supabase mock that resolves to `resolvedValue`.
|
||||||
|
*
|
||||||
|
* The fluent chain used by AlertEventModel:
|
||||||
|
* .from().insert().select().single() → create
|
||||||
|
* .from().select().eq().order()[.eq()] → findActive (awaitable)
|
||||||
|
* .from().update().eq().select().single() → acknowledge / resolve
|
||||||
|
* .from().select().eq().eq().gte().order().limit().single() → findRecentByService
|
||||||
|
* .from().delete().lt().select() → deleteOlderThan (awaitable)
|
||||||
|
*/
|
||||||
|
function makeSupabaseChain(resolvedValue: { data: unknown; error: unknown }) {
|
||||||
|
const chain: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
chain.insert = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.select = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.order = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.eq = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.gte = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.lt = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.limit = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.delete = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.update = vi.fn().mockReturnValue(chain);
|
||||||
|
|
||||||
|
// .single() resolves the promise for create, acknowledge, resolve, findRecentByService
|
||||||
|
chain.single = vi.fn().mockResolvedValue(resolvedValue);
|
||||||
|
|
||||||
|
// Make the chain itself thenable so `await query` works for findActive / deleteOlderThan
|
||||||
|
chain.then = (resolve: (v: unknown) => void, reject: (e: unknown) => void) =>
|
||||||
|
Promise.resolve(resolvedValue).then(resolve, reject);
|
||||||
|
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('AlertEventModel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// create
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
test('creates an alert event with valid data', async () => {
|
||||||
|
const record = makeAlertEventRecord();
|
||||||
|
const chain = makeSupabaseChain({ data: record, error: null });
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
const result = await AlertEventModel.create({
|
||||||
|
service_name: 'claude_ai',
|
||||||
|
alert_type: 'service_down',
|
||||||
|
message: 'API returned 503',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetSupabaseServiceClient).toHaveBeenCalledOnce();
|
||||||
|
expect(chain.insert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
service_name: 'claude_ai',
|
||||||
|
alert_type: 'service_down',
|
||||||
|
message: 'API returned 503',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result).toEqual(record);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults status to active', async () => {
|
||||||
|
const record = makeAlertEventRecord({ status: 'active' });
|
||||||
|
const chain = makeSupabaseChain({ data: record, error: null });
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
await AlertEventModel.create({
|
||||||
|
service_name: 'claude_ai',
|
||||||
|
alert_type: 'service_down',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(chain.insert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'active' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates with explicit status', async () => {
|
||||||
|
const record = makeAlertEventRecord({ status: 'acknowledged' });
|
||||||
|
const chain = makeSupabaseChain({ data: record, error: null });
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
await AlertEventModel.create({
|
||||||
|
service_name: 'claude_ai',
|
||||||
|
alert_type: 'service_down',
|
||||||
|
status: 'acknowledged',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(chain.insert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'acknowledged' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates with details JSONB', async () => {
|
||||||
|
const details = { http_status: 503, endpoint: '/v1/messages' };
|
||||||
|
const record = makeAlertEventRecord({ details });
|
||||||
|
const chain = makeSupabaseChain({ data: record, error: null });
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
const result = await AlertEventModel.create({
|
||||||
|
service_name: 'claude_ai',
|
||||||
|
alert_type: 'service_down',
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(chain.insert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ details })
|
||||||
|
);
|
||||||
|
expect(result.details).toEqual(details);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on empty service_name', async () => {
|
||||||
|
await expect(
|
||||||
|
AlertEventModel.create({ service_name: '', alert_type: 'service_down' })
|
||||||
|
).rejects.toThrow('service_name must be a non-empty string');
|
||||||
|
|
||||||
|
expect(mockGetSupabaseServiceClient).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on invalid alert_type', async () => {
|
||||||
|
await expect(
|
||||||
|
// @ts-expect-error intentionally passing invalid alert_type
|
||||||
|
AlertEventModel.create({ service_name: 'claude_ai', alert_type: 'warning' })
|
||||||
|
).rejects.toThrow('alert_type must be one of');
|
||||||
|
|
||||||
|
expect(mockGetSupabaseServiceClient).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on invalid status', async () => {
|
||||||
|
await expect(
|
||||||
|
AlertEventModel.create({
|
||||||
|
service_name: 'claude_ai',
|
||||||
|
alert_type: 'service_down',
|
||||||
|
// @ts-expect-error intentionally passing invalid status
|
||||||
|
status: 'pending',
|
||||||
|
})
|
||||||
|
).rejects.toThrow('status must be one of');
|
||||||
|
|
||||||
|
expect(mockGetSupabaseServiceClient).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on Supabase error', async () => {
|
||||||
|
const chain = makeSupabaseChain({
|
||||||
|
data: null,
|
||||||
|
error: { message: 'insert constraint violated', code: '23514', details: null },
|
||||||
|
});
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
AlertEventModel.create({ service_name: 'claude_ai', alert_type: 'service_down' })
|
||||||
|
).rejects.toThrow('failed to insert alert event — insert constraint violated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// findActive
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('findActive', () => {
|
||||||
|
test('returns active alerts', async () => {
|
||||||
|
const records = [makeAlertEventRecord(), makeAlertEventRecord({ id: 'alert-uuid-456' })];
|
||||||
|
const chain = makeSupabaseChain({ data: records, error: null });
|
||||||
|
const mockFrom = vi.fn().mockReturnValue(chain);
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: mockFrom } as any);
|
||||||
|
|
||||||
|
const result = await AlertEventModel.findActive();
|
||||||
|
|
||||||
|
expect(mockGetSupabaseServiceClient).toHaveBeenCalledOnce();
|
||||||
|
expect(chain.eq).toHaveBeenCalledWith('status', 'active');
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters by serviceName when provided', async () => {
|
||||||
|
const records = [makeAlertEventRecord()];
|
||||||
|
const chain = makeSupabaseChain({ data: records, error: null });
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
await AlertEventModel.findActive('claude_ai');
|
||||||
|
|
||||||
|
// First .eq call is for status='active', second is for service_name
|
||||||
|
expect(chain.eq).toHaveBeenCalledWith('status', 'active');
|
||||||
|
expect(chain.eq).toHaveBeenCalledWith('service_name', 'claude_ai');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty array when no active alerts', async () => {
|
||||||
|
const chain = makeSupabaseChain({ data: [], error: null });
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
const result = await AlertEventModel.findActive();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// acknowledge
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('acknowledge', () => {
|
||||||
|
test('sets status to acknowledged with timestamp', async () => {
|
||||||
|
const record = makeAlertEventRecord({
|
||||||
|
status: 'acknowledged',
|
||||||
|
acknowledged_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
const chain = makeSupabaseChain({ data: record, error: null });
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
const result = await AlertEventModel.acknowledge('alert-uuid-123');
|
||||||
|
|
||||||
|
expect(mockGetSupabaseServiceClient).toHaveBeenCalledOnce();
|
||||||
|
expect(chain.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'acknowledged',
|
||||||
|
acknowledged_at: expect.any(String),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(chain.eq).toHaveBeenCalledWith('id', 'alert-uuid-123');
|
||||||
|
expect(result.status).toBe('acknowledged');
|
||||||
|
expect(result.acknowledged_at).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws when alert not found', async () => {
|
||||||
|
const chain = makeSupabaseChain({
|
||||||
|
data: null,
|
||||||
|
error: { code: 'PGRST116', message: 'no rows', details: null },
|
||||||
|
});
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
AlertEventModel.acknowledge('nonexistent-id')
|
||||||
|
).rejects.toThrow('alert event not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// resolve
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('resolve', () => {
|
||||||
|
test('sets status to resolved with timestamp', async () => {
|
||||||
|
const record = makeAlertEventRecord({
|
||||||
|
status: 'resolved',
|
||||||
|
resolved_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
const chain = makeSupabaseChain({ data: record, error: null });
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
const result = await AlertEventModel.resolve('alert-uuid-123');
|
||||||
|
|
||||||
|
expect(chain.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'resolved',
|
||||||
|
resolved_at: expect.any(String),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(chain.eq).toHaveBeenCalledWith('id', 'alert-uuid-123');
|
||||||
|
expect(result.status).toBe('resolved');
|
||||||
|
expect(result.resolved_at).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws when alert not found', async () => {
|
||||||
|
const chain = makeSupabaseChain({
|
||||||
|
data: null,
|
||||||
|
error: { code: 'PGRST116', message: 'no rows', details: null },
|
||||||
|
});
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
AlertEventModel.resolve('nonexistent-id')
|
||||||
|
).rejects.toThrow('alert event not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// findRecentByService
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('findRecentByService', () => {
|
||||||
|
test('finds recent alert within time window', async () => {
|
||||||
|
const record = makeAlertEventRecord();
|
||||||
|
const chain = makeSupabaseChain({ data: record, error: null });
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
const result = await AlertEventModel.findRecentByService('claude_ai', 'service_down', 60);
|
||||||
|
|
||||||
|
expect(mockGetSupabaseServiceClient).toHaveBeenCalledOnce();
|
||||||
|
expect(chain.eq).toHaveBeenCalledWith('service_name', 'claude_ai');
|
||||||
|
expect(chain.eq).toHaveBeenCalledWith('alert_type', 'service_down');
|
||||||
|
expect(chain.gte).toHaveBeenCalledWith('created_at', expect.any(String));
|
||||||
|
|
||||||
|
// Verify the cutoff date is approximately 60 minutes ago
|
||||||
|
const gteCall = (chain.gte as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
const cutoffDate = new Date(gteCall[1] as string);
|
||||||
|
const sixtyMinutesAgo = new Date();
|
||||||
|
sixtyMinutesAgo.setMinutes(sixtyMinutesAgo.getMinutes() - 60);
|
||||||
|
const diffMs = Math.abs(cutoffDate.getTime() - sixtyMinutesAgo.getTime());
|
||||||
|
expect(diffMs).toBeLessThan(5000); // Within 5 seconds
|
||||||
|
|
||||||
|
expect(result).toEqual(record);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when no recent alerts', async () => {
|
||||||
|
const chain = makeSupabaseChain({
|
||||||
|
data: null,
|
||||||
|
error: { code: 'PGRST116', message: 'no rows', details: null },
|
||||||
|
});
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
const result = await AlertEventModel.findRecentByService('claude_ai', 'service_down', 60);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// deleteOlderThan
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('deleteOlderThan', () => {
|
||||||
|
test('deletes records older than specified days', async () => {
|
||||||
|
const chain = makeSupabaseChain({
|
||||||
|
data: [{ id: 'alert-1' }, { id: 'alert-2' }],
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
await AlertEventModel.deleteOlderThan(30);
|
||||||
|
|
||||||
|
expect(mockGetSupabaseServiceClient).toHaveBeenCalledOnce();
|
||||||
|
expect(chain.delete).toHaveBeenCalled();
|
||||||
|
expect(chain.lt).toHaveBeenCalledWith('created_at', expect.any(String));
|
||||||
|
|
||||||
|
// Verify the cutoff date is approximately 30 days ago
|
||||||
|
const ltCall = (chain.lt as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
const cutoffDate = new Date(ltCall[1] as string);
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
const diffMs = Math.abs(cutoffDate.getTime() - thirtyDaysAgo.getTime());
|
||||||
|
expect(diffMs).toBeLessThan(5000); // Within 5 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns count of deleted records', async () => {
|
||||||
|
const chain = makeSupabaseChain({
|
||||||
|
data: [{ id: 'alert-1' }, { id: 'alert-2' }, { id: 'alert-3' }],
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
|
||||||
|
|
||||||
|
const count = await AlertEventModel.deleteOlderThan(30);
|
||||||
|
|
||||||
|
expect(count).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user