diff --git a/src/__tests__/health.test.ts b/src/__tests__/health.test.ts index 7866215..9cb4ec5 100644 --- a/src/__tests__/health.test.ts +++ b/src/__tests__/health.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; import { app } from '../index.js'; +import * as dbClient from '../db/client.js'; beforeAll(() => { process.env.NODE_ENV = 'test'; @@ -8,9 +9,46 @@ beforeAll(() => { }); describe('GET /health (public)', () => { - it('returns 200 with correct service name', async () => { + let getConnectionSpy: ReturnType | null = null; + + afterAll(() => { + if (getConnectionSpy) { + getConnectionSpy.mockRestore(); + } + // restore global fetch if stubbed + if ((global as any).fetch && (global as any).fetch.mockRestore) { + (global as any).fetch.mockRestore(); + } + }); + + it('returns 200 with service and dependency details', async () => { + // Ensure dependencies are mocked to pass + const fakeClient = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockResolvedValue({ rows: [] }), + end: vi.fn().mockResolvedValue(undefined), + }; + + getConnectionSpy = vi.spyOn(dbClient, 'getConnection').mockReturnValue(fakeClient as any); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + (global as any).fetch = fetchMock; + + process.env.DATABASE_URL = 'postgres://user:pass@localhost:5432/test'; + const res = await request(app).get('/health'); + expect(res.status).toBe(200); - expect(res.body).toEqual({ status: 'ok', service: 'creditra-backend' }); + expect(res.body).toMatchObject({ + data: { + status: 'ok', + service: 'creditra-backend', + ready: true, + dependencies: { + database: { status: 'ok' }, + horizon: { status: 'ok' }, + }, + }, + error: null, + }); }); }); diff --git a/src/routes/health.ts b/src/routes/health.ts index 4c2631f..d3b6877 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,11 +1,107 @@ import { Router } from 'express'; import { ok } from '../utils/response.js'; +import { getConnection } from '../db/client.js'; +import { resolveConfig } from '../services/horizonListener.js'; export const healthRouter = Router(); -healthRouter.get('/', (_req, res) => { +type DependencyState = 'ok' | 'unconfigured' | 'degraded'; + +interface DependencyHealth { + status: DependencyState; + message?: string; +} + +const DB_CHECK_SQL = 'SELECT 1'; +const DB_CHECK_TIMEOUT_MS = 1000; +const HORIZON_CHECK_TIMEOUT_MS = 2000; + +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`${label} check timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +async function checkDatabase(): Promise { + const databaseUrl = process.env.DATABASE_URL; + + if (!databaseUrl) { + return { status: 'unconfigured', message: 'DATABASE_URL is not set' }; + } + + let client; + try { + client = getConnection(); + if (client.connect) { + await withTimeout(client.connect(), DB_CHECK_TIMEOUT_MS, 'Database connect'); + } + await withTimeout(client.query(DB_CHECK_SQL), DB_CHECK_TIMEOUT_MS, 'Database query'); + return { status: 'ok' }; + } catch (error: unknown) { + return { + status: 'degraded', + message: error instanceof Error ? error.message : 'unknown database error', + }; + } finally { + try { + if (client && typeof client.end === 'function') { + await client.end(); + } + } catch { + // ignore shutdown errors + } + } +} + +async function checkHorizon(): Promise { + const horizonUrl = resolveConfig().horizonUrl; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), HORIZON_CHECK_TIMEOUT_MS); + const response = await fetch(horizonUrl, { signal: controller.signal }); + clearTimeout(timeout); + + if (!response.ok) { + return { status: 'degraded', message: `Horizon returned HTTP ${response.status}` }; + } + + return { status: 'ok' }; + } catch (error: unknown) { + const message = + error instanceof Error && (error.name === 'AbortError' || error.message.includes('timed out')) + ? `Horizon check timed out (${HORIZON_CHECK_TIMEOUT_MS}ms)` + : error instanceof Error + ? error.message + : 'unknown horizon error'; + + return { status: 'degraded', message }; + } +} + +healthRouter.get('/', async (_req, res) => { + const [dbStatus, horizonStatus] = await Promise.all([checkDatabase(), checkHorizon()]); + const ready = dbStatus.status === 'ok' && horizonStatus.status === 'ok'; + ok(res, { status: 'ok', service: 'creditra-backend', + ready, + dependencies: { + database: dbStatus, + horizon: horizonStatus, + }, }); }); \ No newline at end of file diff --git a/tests/api.test.ts b/tests/api.test.ts index d1391fc..5a0bda8 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -9,10 +9,14 @@ describe('API Integration Tests', () => { const response = await request(app).get('/health'); expect(response.status).toBe(200); - expect(response.body).toEqual({ - data: { status: 'ok', service: 'creditra-backend' }, - error: null + expect(response.body).toHaveProperty('data'); + expect(response.body).toHaveProperty('error', null); + expect(response.body.data).toMatchObject({ + status: 'ok', + service: 'creditra-backend', }); + expect(response.body.data).toHaveProperty('ready'); + expect(response.body.data).toHaveProperty('dependencies'); }); }); diff --git a/tests/health.test.ts b/tests/health.test.ts index fb65b36..20bc272 100644 --- a/tests/health.test.ts +++ b/tests/health.test.ts @@ -1,3 +1,4 @@ +import { describe, it, expect } from 'vitest'; import request from 'supertest'; import express from 'express'; import { healthRouter } from '../src/routes/health'; @@ -13,13 +14,17 @@ describe('GET /health', () => { expect(res.status).toBe(200); }); - it('should return correct JSON structure', async () => { + it('should return correct JSON envelope structure', async () => { const res = await request(app).get('/health'); - expect(res.body).toEqual({ + expect(res.body).toHaveProperty('data'); + expect(res.body).toHaveProperty('error', null); + expect(res.body.data).toMatchObject({ status: 'ok', service: 'creditra-backend', }); + expect(res.body.data).toHaveProperty('ready'); + expect(res.body.data).toHaveProperty('dependencies'); }); it('should have content-type application/json', async () => {