From 61c2b9fc7336b57fb718a950b038e89d0be6d9ff Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 24 Feb 2026 11:54:03 -0500 Subject: [PATCH] test(01-02): add HealthCheckModel unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../__tests__/models/HealthCheckModel.test.ts | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 backend/src/__tests__/models/HealthCheckModel.test.ts diff --git a/backend/src/__tests__/models/HealthCheckModel.test.ts b/backend/src/__tests__/models/HealthCheckModel.test.ts new file mode 100644 index 0000000..e5cbf79 --- /dev/null +++ b/backend/src/__tests__/models/HealthCheckModel.test.ts @@ -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 { + 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 = {}; + + 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).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); + }); + }); +});