test(02-02): add healthProbeService unit tests

- 9 tests covering all 4 probers and orchestrator
- Verifies all probes return 4 ProbeResults with correct service names
- Verifies results persisted via HealthCheckModel.create 4 times
- Verifies one probe failure does not abort other probes
- Verifies LLM probe 429 returns degraded not down
- Verifies Supabase probe uses getPostgresPool (not PostgREST)
- Verifies Firebase Auth distinguishes expected vs unexpected errors
- Verifies latency_ms is a non-negative number
- Verifies HealthCheckModel.create failure is isolated
This commit is contained in:
admin
2026-02-24 14:23:35 -05:00
parent 41298262d6
commit a8ba884043

View File

@@ -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);
});
});