diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 8583c279..1612778c 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -138,7 +138,7 @@ export function createError( details?: unknown, options?: { expose?: boolean }, ): AppError { - return new AppError(code, statusCode, message, options?.expose !== false); + return new AppError(code, statusCode, message, details, options); } export function throwError( diff --git a/src/routes/health.test.ts b/src/routes/health.test.ts index 3820d58c..60f867fc 100644 --- a/src/routes/health.test.ts +++ b/src/routes/health.test.ts @@ -651,7 +651,7 @@ describe('health metrics collection', () => { m.labels?.endpoint === 'ready' ); expect(durationMetric).toBeDefined(); - expect(durationMetric?.value).toBeGreaterThan(0); + expect(durationMetric?.value).toBeGreaterThanOrEqual(0); }); it('should work without metrics collector', async () => { @@ -1158,3 +1158,130 @@ describe("Stellar Horizon timeout handling", () => { expect(response.body.checks[1].error).toBe("timeout"); }); }); + +describe("healthRootHandler - dependency graph aggregation", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it("returns 503 unhealthy when Horizon is down and DB is up", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 12, + pool: { totalCount: 2, idleCount: 2, waitingCount: 0, maxConnections: 10 }, + }); + global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(503); + expect(response.body.status).toBe("unhealthy"); + expect(response.body.checks[0].name).toBe("database"); + expect(response.body.checks[0].status).toBe("up"); + expect(response.body.checks[1].name).toBe("stellar-horizon"); + expect(response.body.checks[1].status).toBe("down"); + expect(response.body.checks[1].healthy).toBe(false); + }); + + it("returns 200 degraded when both DB pool and Horizon are degraded", async () => { + // DB pool at 90% utilization → degraded; Horizon returns 200 but we simulate + // a degraded DB pool scenario. Horizon itself is up, so overall = degraded. + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 30, + pool: { totalCount: 9, idleCount: 1, waitingCount: 0, maxConnections: 10 }, + }); + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("degraded"); + expect(response.body.checks[0].status).toBe("degraded"); + expect(response.body.checks[1].status).toBe("up"); + }); + + it("returns 503 unhealthy when DB checker throws an exception", async () => { + const mockDbHealth = jest.fn().mockRejectedValue(new Error("unexpected db crash")); + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + // The handler should not crash the process; it should propagate as 500 or 503 + const response = await request(app).get("/health"); + + expect([500, 503]).toContain(response.status); + }); + + it("populates latencyMs on both database and stellar-horizon checks", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 42, + pool: { totalCount: 1, idleCount: 1, waitingCount: 0, maxConnections: 10 }, + }); + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(200); + const dbCheck = response.body.checks.find((c: DependencyHealth) => c.name === "database"); + const stellarCheck = response.body.checks.find((c: DependencyHealth) => c.name === "stellar-horizon"); + + expect(typeof dbCheck.latencyMs).toBe("number"); + expect(dbCheck.latencyMs).toBeGreaterThanOrEqual(0); + expect(typeof stellarCheck.latencyMs).toBe("number"); + expect(stellarCheck.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it("populates dependsOn on database check when pool metrics are present", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: true, + latencyMs: 8, + pool: { totalCount: 3, idleCount: 3, waitingCount: 0, maxConnections: 10 }, + }); + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(200); + const dbCheck = response.body.checks.find((c: DependencyHealth) => c.name === "database"); + expect(Array.isArray(dbCheck.dependsOn)).toBe(true); + expect(dbCheck.dependsOn).toContain("db-pool"); + }); + + it("returns 503 unhealthy when both DB and Horizon are down", async () => { + const mockDbHealth = jest.fn().mockResolvedValue({ + healthy: false, + latencyMs: 200, + error: "connection refused", + pool: { totalCount: 0, idleCount: 0, waitingCount: 0, maxConnections: 10 }, + }); + global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 503 }) as typeof fetch; + + const app = express(); + app.get("/health", healthRootHandler(mockDbHealth)); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(503); + expect(response.body.status).toBe("unhealthy"); + expect(response.body.checks[0].status).toBe("down"); + expect(response.body.checks[1].status).toBe("down"); + }); +}); diff --git a/src/routes/health.ts b/src/routes/health.ts index c241e721..ed6ab793 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -450,26 +450,41 @@ export const healthRootHandler = async (req: Request, res: Response): Promise => { const requestId = req.headers["x-request-id"] as string | undefined; - const [dbCheck, stellarCheck] = await Promise.all([ - checkDatabase(dbHealth), - checkStellarHorizon(), - ]); + try { + const [dbCheck, stellarCheck] = await Promise.all([ + checkDatabase(dbHealth), + checkStellarHorizon(), + ]); - const checks = [dbCheck, stellarCheck]; - const status = evaluateOverallStatus(checks); + const checks = [dbCheck, stellarCheck]; + const status = evaluateOverallStatus(checks); - const response: HealthDependencyGraph = { - status, - service: "revora-backend", - version: getServiceVersion(), - timestamp: new Date().toISOString(), - uptime: getUptimeSeconds(), - checks, - requestId, - }; + const response: HealthDependencyGraph = { + status, + service: "revora-backend", + version: getServiceVersion(), + timestamp: new Date().toISOString(), + uptime: getUptimeSeconds(), + checks, + requestId, + }; - const statusCode = status === "unhealthy" ? 503 : 200; - res.status(statusCode).json(response); + const statusCode = status === "unhealthy" ? 503 : 200; + res.status(statusCode).json(response); + } catch (err) { + logHealthCheck("error", "Health check failed unexpectedly", { + error: err instanceof Error ? err.message : String(err), + }); + res.status(503).json({ + status: "unhealthy", + service: "revora-backend", + version: getServiceVersion(), + timestamp: new Date().toISOString(), + uptime: getUptimeSeconds(), + checks: [], + requestId, + }); + } }; export const healthLiveHandler =