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:
317
backend/src/__tests__/unit/healthProbeService.test.ts
Normal file
317
backend/src/__tests__/unit/healthProbeService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user