diff --git a/backend/src/__tests__/unit/healthProbeService.test.ts b/backend/src/__tests__/unit/healthProbeService.test.ts new file mode 100644 index 0000000..2b45c91 --- /dev/null +++ b/backend/src/__tests__/unit/healthProbeService.test.ts @@ -0,0 +1,317 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +// ============================================================================= +// Mocks — vi.mock is hoisted; factories must not reference outer variables +// ============================================================================= + +vi.mock('../../models/HealthCheckModel', () => ({ + HealthCheckModel: { + create: vi.fn().mockResolvedValue({ id: 'uuid-1', service_name: 'test', status: 'healthy' }), + }, +})); + +vi.mock('../../config/supabase', () => ({ + getPostgresPool: vi.fn().mockReturnValue({ + query: vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }), + }), + getSupabaseServiceClient: vi.fn(), +})); + +vi.mock('@google-cloud/documentai', () => ({ + DocumentProcessorServiceClient: vi.fn().mockImplementation(() => ({ + listProcessors: vi.fn().mockResolvedValue([[]]), + })), +})); + +vi.mock('@anthropic-ai/sdk', () => ({ + default: vi.fn().mockImplementation(() => ({ + messages: { + create: vi.fn().mockResolvedValue({ + id: 'msg_01', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Hi' }], + model: 'claude-haiku-4-5', + stop_reason: 'end_turn', + usage: { input_tokens: 5, output_tokens: 2 }, + }), + }, + })), +})); + +vi.mock('firebase-admin', () => ({ + default: { + auth: vi.fn().mockReturnValue({ + verifyIdToken: vi.fn().mockRejectedValue( + new Error('Decoding Firebase ID token failed. Make sure you passed the entire string JWT which represents an ID token.') + ), + }), + apps: [], + initializeApp: vi.fn(), + }, +})); + +vi.mock('../../config/env', () => ({ + config: { + googleCloud: { + projectId: 'test-project', + documentAiLocation: 'us', + }, + }, +})); + +vi.mock('../../utils/logger', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// ============================================================================= +// Imports AFTER vi.mock declarations +// ============================================================================= + +import { healthProbeService } from '../../services/healthProbeService'; +import { HealthCheckModel } from '../../models/HealthCheckModel'; +import { getPostgresPool } from '../../config/supabase'; +import { DocumentProcessorServiceClient } from '@google-cloud/documentai'; +import Anthropic from '@anthropic-ai/sdk'; +import admin from 'firebase-admin'; + +const mockHealthCheckModelCreate = vi.mocked(HealthCheckModel.create); +const mockGetPostgresPool = vi.mocked(getPostgresPool); +const mockDocumentProcessorServiceClient = vi.mocked(DocumentProcessorServiceClient); +const mockAnthropic = vi.mocked(Anthropic); +const mockAdmin = vi.mocked(admin); + +// ============================================================================= +// Tests +// ============================================================================= + +describe('healthProbeService', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Reset default mocks after clearAllMocks + mockHealthCheckModelCreate.mockResolvedValue({ + id: 'uuid-1', + service_name: 'test', + status: 'healthy', + latency_ms: 100, + checked_at: new Date().toISOString(), + error_message: null, + probe_details: null, + created_at: new Date().toISOString(), + }); + + mockGetPostgresPool.mockReturnValue({ + query: vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }), + } as any); + + mockDocumentProcessorServiceClient.mockImplementation((() => ({ + listProcessors: vi.fn().mockResolvedValue([[]]), + })) as any); + + mockAnthropic.mockImplementation((() => ({ + messages: { + create: vi.fn().mockResolvedValue({ + id: 'msg_01', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Hi' }], + model: 'claude-haiku-4-5', + stop_reason: 'end_turn', + usage: { input_tokens: 5, output_tokens: 2 }, + }), + }, + })) as any); + + mockAdmin.auth.mockReturnValue({ + verifyIdToken: vi.fn().mockRejectedValue( + new Error('Decoding Firebase ID token failed. Make sure you passed the entire string JWT which represents an ID token.') + ), + } as any); + }); + + // =========================================================================== + // Test 1: All probes healthy — returns 4 ProbeResults + // =========================================================================== + + test('all probes healthy — returns 4 ProbeResults with status healthy', async () => { + const results = await healthProbeService.runAllProbes(); + + expect(results).toHaveLength(4); + + const serviceNames = results.map((r) => r.service_name); + expect(serviceNames).toContain('document_ai'); + expect(serviceNames).toContain('llm_api'); + expect(serviceNames).toContain('supabase'); + expect(serviceNames).toContain('firebase_auth'); + + for (const result of results) { + expect(result.status).toBe('healthy'); + expect(result.latency_ms).toBeGreaterThanOrEqual(0); + } + }); + + // =========================================================================== + // Test 2: Each result persisted via HealthCheckModel.create + // =========================================================================== + + test('each result persisted via HealthCheckModel.create with correct service names', async () => { + await healthProbeService.runAllProbes(); + + expect(mockHealthCheckModelCreate).toHaveBeenCalledTimes(4); + + const calledServiceNames = mockHealthCheckModelCreate.mock.calls.map( + (call) => call[0].service_name + ); + + expect(calledServiceNames).toContain('document_ai'); + expect(calledServiceNames).toContain('llm_api'); + expect(calledServiceNames).toContain('supabase'); + expect(calledServiceNames).toContain('firebase_auth'); + }); + + // =========================================================================== + // Test 3: One probe throws — others still run + // =========================================================================== + + test('one probe throws — others still run and all 4 HealthCheckModel.create calls happen', async () => { + // Make Document AI throw + mockDocumentProcessorServiceClient.mockImplementation((() => ({ + listProcessors: vi.fn().mockRejectedValue(new Error('Document AI network error')), + })) as any); + + const results = await healthProbeService.runAllProbes(); + + // All 4 probes should return results + expect(results).toHaveLength(4); + + // The failed probe should be 'down' + const docAiResult = results.find((r) => r.service_name === 'document_ai'); + expect(docAiResult).toBeDefined(); + expect(docAiResult!.status).toBe('down'); + + // Other probes should still be healthy + const otherResults = results.filter((r) => r.service_name !== 'document_ai'); + for (const result of otherResults) { + expect(result.status).toBe('healthy'); + } + + // All 4 HealthCheckModel.create calls still happen + expect(mockHealthCheckModelCreate).toHaveBeenCalledTimes(4); + }); + + // =========================================================================== + // Test 4: LLM probe 429 error returns 'degraded' not 'down' + // =========================================================================== + + test('LLM probe 429 error returns degraded not down', async () => { + mockAnthropic.mockImplementation((() => ({ + messages: { + create: vi.fn().mockRejectedValue( + new Error('429 Too Many Requests: rate limit exceeded') + ), + }, + })) as any); + + const results = await healthProbeService.runAllProbes(); + + const llmResult = results.find((r) => r.service_name === 'llm_api'); + expect(llmResult).toBeDefined(); + expect(llmResult!.status).toBe('degraded'); + expect(llmResult!.error_message).toContain('429'); + }); + + // =========================================================================== + // Test 5: Supabase probe uses getPostgresPool not getSupabaseServiceClient + // =========================================================================== + + test('Supabase probe uses getPostgresPool not getSupabaseServiceClient', async () => { + const mockQuery = vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }); + mockGetPostgresPool.mockReturnValue({ query: mockQuery } as any); + + await healthProbeService.runAllProbes(); + + expect(mockGetPostgresPool).toHaveBeenCalled(); + expect(mockQuery).toHaveBeenCalledWith('SELECT 1'); + }); + + // =========================================================================== + // Test 6: Firebase Auth probe — expected error = healthy + // =========================================================================== + + test('Firebase Auth probe — expected Decoding error returns healthy', async () => { + mockAdmin.auth.mockReturnValue({ + verifyIdToken: vi.fn().mockRejectedValue( + new Error('Decoding Firebase ID token failed. Make sure you passed the entire string JWT which represents an ID token.') + ), + } as any); + + const results = await healthProbeService.runAllProbes(); + + const firebaseResult = results.find((r) => r.service_name === 'firebase_auth'); + expect(firebaseResult).toBeDefined(); + expect(firebaseResult!.status).toBe('healthy'); + }); + + // =========================================================================== + // Test 7: Firebase Auth probe — unexpected error = down + // =========================================================================== + + test('Firebase Auth probe — network error returns down', async () => { + mockAdmin.auth.mockReturnValue({ + verifyIdToken: vi.fn().mockRejectedValue( + new Error('ECONNREFUSED: connection refused to metadata server') + ), + } as any); + + const results = await healthProbeService.runAllProbes(); + + const firebaseResult = results.find((r) => r.service_name === 'firebase_auth'); + expect(firebaseResult).toBeDefined(); + expect(firebaseResult!.status).toBe('down'); + expect(firebaseResult!.error_message).toContain('ECONNREFUSED'); + }); + + // =========================================================================== + // Test 8: Latency measured correctly + // =========================================================================== + + test('latency measured correctly — latency_ms is a non-negative number', async () => { + const results = await healthProbeService.runAllProbes(); + + for (const result of results) { + expect(typeof result.latency_ms).toBe('number'); + expect(result.latency_ms).toBeGreaterThanOrEqual(0); + } + }); + + // =========================================================================== + // Test 9: HealthCheckModel.create failure does not abort remaining probes + // =========================================================================== + + test('HealthCheckModel.create failure does not abort remaining probes', async () => { + // Make create fail for the first 2 calls, then succeed + mockHealthCheckModelCreate + .mockRejectedValueOnce(new Error('DB write failed')) + .mockRejectedValueOnce(new Error('DB write failed')) + .mockResolvedValue({ + id: 'uuid-1', + service_name: 'test', + status: 'healthy', + latency_ms: 100, + checked_at: new Date().toISOString(), + error_message: null, + probe_details: null, + created_at: new Date().toISOString(), + }); + + // Should not throw even if persistence fails + const results = await healthProbeService.runAllProbes(); + expect(results).toHaveLength(4); + }); +});