Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions src/__tests__/health.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,54 @@
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';
process.env.API_KEYS = 'health-test-key';
});

describe('GET /health (public)', () => {
it('returns 200 with correct service name', async () => {
let getConnectionSpy: ReturnType<typeof vi.spyOn> | null = null;

afterAll(() => {
if (getConnectionSpy) {
getConnectionSpy.mockRestore();
}
// restore global fetch if stubbed
if ((global as any).fetch && (global as any).fetch.mockRestore) {

Check warning on line 19 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 18.x)

Unexpected any. Specify a different type

Check warning on line 19 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 18.x)

Unexpected any. Specify a different type

Check warning on line 19 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 20.x)

Unexpected any. Specify a different type

Check warning on line 19 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 20.x)

Unexpected any. Specify a different type

Check warning on line 19 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 22.x)

Unexpected any. Specify a different type

Check warning on line 19 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 22.x)

Unexpected any. Specify a different type
(global as any).fetch.mockRestore();

Check warning on line 20 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 18.x)

Unexpected any. Specify a different type

Check warning on line 20 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 20.x)

Unexpected any. Specify a different type

Check warning on line 20 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 22.x)

Unexpected any. Specify a different type
}
});

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

Check warning on line 32 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 18.x)

Unexpected any. Specify a different type

Check warning on line 32 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 20.x)

Unexpected any. Specify a different type

Check warning on line 32 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 22.x)

Unexpected any. Specify a different type
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
(global as any).fetch = fetchMock;

Check warning on line 34 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 18.x)

Unexpected any. Specify a different type

Check warning on line 34 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 20.x)

Unexpected any. Specify a different type

Check warning on line 34 in src/__tests__/health.test.ts

View workflow job for this annotation

GitHub Actions / Test & Lint (Node 22.x)

Unexpected any. Specify a different type

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,
});
});
});
98 changes: 97 additions & 1 deletion src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -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<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
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<DependencyHealth> {
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<DependencyHealth> {
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,
},
});
});
10 changes: 7 additions & 3 deletions tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand Down
9 changes: 7 additions & 2 deletions tests/health.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import express from 'express';
import { healthRouter } from '../src/routes/health';
Expand All @@ -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 () => {
Expand Down
Loading