diff --git a/src/server/routes/health.test.ts b/src/server/routes/health.test.ts new file mode 100644 index 00000000..14c12361 --- /dev/null +++ b/src/server/routes/health.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { healthMiddleware } from "./health.js"; +import type { ApiClient } from "@/shared/api/client.js"; + +function createMockApiClient( + behavior: "ok" | "error" | "timeout" = "ok", +): ApiClient { + const client = { + get: vi.fn(), + post: vi.fn(), + } as unknown as ApiClient; + + if (behavior === "ok") { + vi.mocked(client.get).mockResolvedValue({ status: "ok" }); + } else if (behavior === "error") { + vi.mocked(client.get).mockRejectedValue(new Error("upstream down")); + } else { + vi.mocked(client.get).mockImplementation( + () => new Promise(() => {}), // never resolves + ); + } + + return client; +} + +function createMockRes() { + const res = { + statusCode: 200, + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + setHeader: vi.fn(), + }; + return res; +} + +describe("healthMiddleware", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it("returns 200 when upstream is reachable", async () => { + const apiClient = createMockApiClient("ok"); + const handler = healthMiddleware({ apiClient }); + + const req = {} as any; + const res = createMockRes(); + const next = vi.fn(); + + await handler(req, res as any, next); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ status: "ok" }); + }); + + it("returns 503 when upstream ping fails", async () => { + const apiClient = createMockApiClient("error"); + const handler = healthMiddleware({ apiClient }); + + const req = {} as any; + const res = createMockRes(); + const next = vi.fn(); + + await handler(req, res as any, next); + + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith({ + status: "degraded", + reason: "upstream_unreachable", + }); + }); + + it("returns 200 if last success is within 60s even if current ping fails", async () => { + const apiClient = createMockApiClient("ok"); + const handler = healthMiddleware({ apiClient }); + + const req = {} as any; + const res1 = createMockRes(); + await handler(req, res1 as any, vi.fn()); + expect(res1.status).toHaveBeenCalledWith(200); + + // Now make it fail + vi.mocked(apiClient.get).mockRejectedValue(new Error("fail")); + + // Advance 30s — still within 60s window + vi.advanceTimersByTime(30_000); + + const res2 = createMockRes(); + await handler(req, res2 as any, vi.fn()); + expect(res2.status).toHaveBeenCalledWith(200); + }); + + it("returns 503 if last success is older than 60s", async () => { + const apiClient = createMockApiClient("ok"); + const handler = healthMiddleware({ apiClient }); + + const req = {} as any; + const res1 = createMockRes(); + await handler(req, res1 as any, vi.fn()); + expect(res1.status).toHaveBeenCalledWith(200); + + // Now make it fail + vi.mocked(apiClient.get).mockRejectedValue(new Error("fail")); + + // Advance 61s — beyond 60s window + vi.advanceTimersByTime(61_000); + + const res2 = createMockRes(); + await handler(req, res2 as any, vi.fn()); + expect(res2.status).toHaveBeenCalledWith(503); + }); + + it("respects custom upstreamTimeoutMs", async () => { + const apiClient = createMockApiClient("timeout"); + const handler = healthMiddleware({ + apiClient, + upstreamTimeoutMs: 100, + }); + + const req = {} as any; + const res = createMockRes(); + const next = vi.fn(); + + // The handler should abort the ping after 100ms + const promise = handler(req, res as any, next); + vi.advanceTimersByTime(200); + await promise; + + expect(res.status).toHaveBeenCalledWith(503); + }); + + it("does not call next — health is a terminal response", async () => { + const apiClient = createMockApiClient("ok"); + const handler = healthMiddleware({ apiClient }); + + const req = {} as any; + const res = createMockRes(); + const next = vi.fn(); + + await handler(req, res as any, next); + + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/routes/health.ts b/src/server/routes/health.ts new file mode 100644 index 00000000..2fbc92c1 --- /dev/null +++ b/src/server/routes/health.ts @@ -0,0 +1,60 @@ +import type { ApiClient } from "@/shared/api/client.js"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +export interface HealthMiddlewareOptions { + apiClient: ApiClient; + upstreamTimeoutMs?: number; +} + +const STALE_THRESHOLD_MS = 60_000; +const DEFAULT_UPSTREAM_TIMEOUT_MS = 5_000; + +/** + * Factory that creates a health-check handler. Returns 200 if the last + * successful upstream ping is within 60 s, 503 otherwise. + * + * This is an Express-style `(req, res, next)` handler — it does NOT + * auto-register itself as Modern.js middleware. Registration happens + * in a future integration step. + */ +export function healthMiddleware(options: HealthMiddlewareOptions) { + const { apiClient, upstreamTimeoutMs = DEFAULT_UPSTREAM_TIMEOUT_MS } = + options; + + let lastSuccessTs = 0; + + return async ( + _req: IncomingMessage, + res: ServerResponse & { + status: (code: number) => ServerResponse; + json: (body: unknown) => void; + }, + _next: () => void, + ): Promise => { + // Attempt to ping the upstream API + try { + await Promise.race([ + apiClient.get("/health"), + new Promise((_resolve, reject) => + setTimeout( + () => reject(new Error("upstream_timeout")), + upstreamTimeoutMs, + ), + ), + ]); + lastSuccessTs = Date.now(); + } catch { + // ping failed — rely on cached lastSuccessTs + } + + const age = Date.now() - lastSuccessTs; + + if (lastSuccessTs > 0 && age < STALE_THRESHOLD_MS) { + res.status(200).json({ status: "ok" }); + } else { + res + .status(503) + .json({ status: "degraded", reason: "upstream_unreachable" }); + } + }; +}