test(01-02): add HealthCheckModel unit tests

- Tests cover create (valid, minimal, probe_details), input validation (empty name, invalid status)
- Supabase error handling (throws, logs error)
- findLatestByService (found, not found — PGRST116 null return)
- findAll (default limit 100, filtered by service, custom limit)
- deleteOlderThan (cutoff date calculation, returns count)
- Establishes Supabase chainable mock pattern for future model tests
- Mocks getSupabaseServiceClient confirming INFR-04 compliance
This commit is contained in:
admin
2026-02-24 11:54:03 -05:00
parent 1e4bc99fd1
commit 61c2b9fc73

View File

@@ -0,0 +1,321 @@
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 { HealthCheckModel, ServiceHealthCheck } from '../../models/HealthCheckModel';
import { getSupabaseServiceClient } from '../../config/supabase';
import { logger } from '../../utils/logger';
const mockGetSupabaseServiceClient = vi.mocked(getSupabaseServiceClient);
const mockLogger = vi.mocked(logger);
// =============================================================================
// Helpers
// =============================================================================
function makeHealthCheckRecord(overrides: Partial<ServiceHealthCheck> = {}): ServiceHealthCheck {
return {
id: 'uuid-123',
service_name: 'document_ai',
status: 'healthy',
latency_ms: 150,
checked_at: new Date().toISOString(),
error_message: null,
probe_details: null,
created_at: new Date().toISOString(),
...overrides,
};
}
/**
* Build a chainable Supabase mock that returns `resolvedValue` from the
* terminal method (single or the awaited query itself).
*
* The fluent chain used by HealthCheckModel:
* .from().insert().select().single() → create
* .from().select().eq().order().limit().single() → findLatestByService
* .from().select().order().limit()[.eq()] → findAll (awaitable query)
* .from().delete().lt().select() → deleteOlderThan (awaitable query)
*/
function makeSupabaseChain(resolvedValue: { data: unknown; error: unknown }) {
// Most chainable methods just return the chain; terminal nodes resolve
const chain: Record<string, unknown> = {};
const makeMethod = (returnSelf = true) =>
vi.fn((..._args: unknown[]) => (returnSelf ? chain : Promise.resolve(resolvedValue)));
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.lt = vi.fn().mockReturnValue(chain);
chain.delete = vi.fn().mockReturnValue(chain);
chain.update = vi.fn().mockReturnValue(chain);
// .limit() is the final awaitable in findAll / used before .select in deleteOlderThan
chain.limit = vi.fn().mockReturnValue(chain);
// .single() resolves the promise for create and findLatestByService
chain.single = vi.fn().mockResolvedValue(resolvedValue);
// Make the chain itself thenable so `await query` works for findAll / deleteOlderThan
chain.then = (resolve: (v: unknown) => void, reject: (e: unknown) => void) =>
Promise.resolve(resolvedValue).then(resolve, reject);
return chain;
}
// =============================================================================
// Tests
// =============================================================================
describe('HealthCheckModel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ===========================================================================
// create
// ===========================================================================
describe('create', () => {
test('creates a health check with valid data', async () => {
const record = makeHealthCheckRecord();
const chain = makeSupabaseChain({ data: record, error: null });
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
const result = await HealthCheckModel.create({
service_name: 'document_ai',
status: 'healthy',
latency_ms: 150,
});
expect(mockGetSupabaseServiceClient).toHaveBeenCalledOnce();
expect(chain.insert).toHaveBeenCalledWith(
expect.objectContaining({
service_name: 'document_ai',
status: 'healthy',
latency_ms: 150,
})
);
expect(result).toEqual(record);
});
test('creates a health check with minimal data', async () => {
const record = makeHealthCheckRecord({ latency_ms: null });
const chain = makeSupabaseChain({ data: record, error: null });
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
const result = await HealthCheckModel.create({
service_name: 'document_ai',
status: 'healthy',
});
expect(chain.insert).toHaveBeenCalledWith(
expect.objectContaining({
service_name: 'document_ai',
status: 'healthy',
latency_ms: null,
error_message: null,
probe_details: null,
})
);
expect(result).toEqual(record);
});
test('creates a health check with probe_details', async () => {
const probeDetails = { http_status: 200, response_body: 'ok' };
const record = makeHealthCheckRecord({ probe_details: probeDetails });
const chain = makeSupabaseChain({ data: record, error: null });
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
const result = await HealthCheckModel.create({
service_name: 'document_ai',
status: 'healthy',
probe_details: probeDetails,
});
expect(chain.insert).toHaveBeenCalledWith(
expect.objectContaining({ probe_details: probeDetails })
);
expect(result.probe_details).toEqual(probeDetails);
});
test('throws on empty service_name', async () => {
await expect(
HealthCheckModel.create({ service_name: '', status: 'healthy' })
).rejects.toThrow('service_name must be a non-empty string');
// Supabase must not be called for validation errors
expect(mockGetSupabaseServiceClient).not.toHaveBeenCalled();
});
test('throws on invalid status', async () => {
await expect(
// @ts-expect-error intentionally passing invalid status
HealthCheckModel.create({ service_name: 'document_ai', status: 'unknown' })
).rejects.toThrow('status must be one of');
expect(mockGetSupabaseServiceClient).not.toHaveBeenCalled();
});
test('throws on Supabase error', async () => {
const chain = makeSupabaseChain({
data: null,
error: { message: 'connection failed', code: '08000', details: null },
});
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
await expect(
HealthCheckModel.create({ service_name: 'document_ai', status: 'healthy' })
).rejects.toThrow('failed to insert health check — connection failed');
});
test('logs error on Supabase failure', async () => {
const chain = makeSupabaseChain({
data: null,
error: { message: 'connection failed', code: '08000', details: null },
});
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
await expect(
HealthCheckModel.create({ service_name: 'document_ai', status: 'healthy' })
).rejects.toThrow();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Supabase insert failed'),
expect.objectContaining({ error: 'connection failed' })
);
});
});
// ===========================================================================
// findLatestByService
// ===========================================================================
describe('findLatestByService', () => {
test('returns latest health check for service', async () => {
const record = makeHealthCheckRecord();
const chain = makeSupabaseChain({ data: record, error: null });
const mockFrom = vi.fn().mockReturnValue(chain);
mockGetSupabaseServiceClient.mockReturnValue({ from: mockFrom } as any);
const result = await HealthCheckModel.findLatestByService('document_ai');
expect(mockGetSupabaseServiceClient).toHaveBeenCalledOnce();
expect(mockFrom).toHaveBeenCalledWith('service_health_checks');
expect(chain.eq).toHaveBeenCalledWith('service_name', 'document_ai');
expect(chain.order).toHaveBeenCalledWith('checked_at', { ascending: false });
expect(chain.limit).toHaveBeenCalledWith(1);
expect(result).toEqual(record);
});
test('returns null when no records found', 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 HealthCheckModel.findLatestByService('unknown_service');
expect(result).toBeNull();
});
});
// ===========================================================================
// findAll
// ===========================================================================
describe('findAll', () => {
test('returns health checks with default limit', async () => {
const records = [makeHealthCheckRecord(), makeHealthCheckRecord({ id: 'uuid-456' })];
const chain = makeSupabaseChain({ data: records, error: null });
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
const result = await HealthCheckModel.findAll();
expect(chain.limit).toHaveBeenCalledWith(100);
expect(result).toHaveLength(2);
});
test('filters by serviceName when provided', async () => {
const records = [makeHealthCheckRecord()];
const chain = makeSupabaseChain({ data: records, error: null });
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
const result = await HealthCheckModel.findAll({ serviceName: 'document_ai' });
expect(chain.eq).toHaveBeenCalledWith('service_name', 'document_ai');
expect(result).toHaveLength(1);
});
test('respects custom limit', async () => {
const chain = makeSupabaseChain({ data: [], error: null });
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
await HealthCheckModel.findAll({ limit: 50 });
expect(chain.limit).toHaveBeenCalledWith(50);
});
});
// ===========================================================================
// deleteOlderThan
// ===========================================================================
describe('deleteOlderThan', () => {
test('deletes records older than specified days', async () => {
const chain = makeSupabaseChain({
data: [{ id: 'uuid-1' }, { id: 'uuid-2' }],
error: null,
});
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
await HealthCheckModel.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: 'uuid-1' }, { id: 'uuid-2' }, { id: 'uuid-3' }],
error: null,
});
mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any);
const count = await HealthCheckModel.deleteOlderThan(30);
expect(count).toBe(3);
});
});
});