diff --git a/README.md b/README.md index 1fb5607e..cb6c913a 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Once running: | `GET /healthz/dependencies` | None | Shallow dependency probe — Postgres, Soroban RPC, Horizon, webhook queue (Redis). Cached for 5 s. Returns 200/207/503. | | `GET /api/health/ready` | None | **Deep readiness check** — runs four parallel probes with 1-second timeouts each. Returns 200 when ready, 503 when unready. | | `GET /api/indexer/health` | None | Indexer health — probes external dependencies (Postgres + Soroban RPC) and compares the persisted cursor against the chain tip. Returns `"ok"` / `"degraded"` / `"down"` with dependency statuses in `dependencies` and lag data in `data`. Always HTTP 200. Supports [ETag / conditional GET](#etag--conditional-get-caching). | +| `GET /api/recommendations/health` | None | Recommendations subsystem health — probes the two runtime dependencies the recommendations pipeline relies on (Postgres + Soroban RPC). Returns 200 when all pass, 503 when any is down. Response shape mirrors `GET /api/predictions/health`. | ### `GET /api/health/ready` response diff --git a/docs/recommendations-health.md b/docs/recommendations-health.md new file mode 100644 index 00000000..bd9fc4c1 --- /dev/null +++ b/docs/recommendations-health.md @@ -0,0 +1,152 @@ +# `GET /api/recommendations/health` + +Health probe for the `/api/recommendations` subsystem. Reports the status +of the two external dependencies the recommendations pipeline relies on. + +--- + +## Why it exists + +The recommendations endpoint surfaces personalised markets by querying +Postgres (for prediction history and market data) against a corpus of +markets that were indexed from the Soroban chain. If either dependency is +unavailable, personalised recommendations cannot be served. This probe lets +orchestrators and dashboards surface the root cause quickly. + +--- + +## Request + +``` +GET /api/recommendations/health +``` + +No authentication required. No request body or query parameters. + +Pass `X-Correlation-Id` to correlate the probe response with your +distributed-trace or alerting system: + +``` +X-Correlation-Id: my-trace-id-42 +``` + +--- + +## Response + +### 200 OK — all dependencies healthy + +```json +{ + "status": "ok", + "correlationId": "3a6d1f2c-...", + "checkedAt": "2026-07-28T19:27:42.000Z", + "dependencies": { + "database": { "status": "ok", "latencyMs": 4 }, + "sorobanRpc": { "status": "ok", "latencyMs": 18 } + } +} +``` + +### 503 Service Unavailable — at least one dependency is down + +```json +{ + "status": "down", + "correlationId": "3a6d1f2c-...", + "checkedAt": "2026-07-28T19:27:42.000Z", + "dependencies": { + "database": { "status": "ok", "latencyMs": 3 }, + "sorobanRpc": { "status": "down", "latencyMs": 5000, "error": "Soroban RPC unavailable" } + } +} +``` + +### Fields + +| Field | Type | Description | +|---|---|---| +| `status` | `"ok"` \| `"down"` | Composite: `"ok"` only when **both** probes pass | +| `correlationId` | string | Echoes `X-Correlation-Id` header, or a generated UUID | +| `checkedAt` | ISO-8601 string | Timestamp of the probe run | +| `dependencies.database.status` | `"ok"` \| `"down"` | Postgres reachability | +| `dependencies.database.latencyMs` | number | Round-trip time in ms | +| `dependencies.database.error` | string? | Present only when `status = "down"` | +| `dependencies.sorobanRpc.status` | `"ok"` \| `"down"` | Soroban RPC reachability | +| `dependencies.sorobanRpc.latencyMs` | number | Round-trip time in ms | +| `dependencies.sorobanRpc.error` | string? | Present only when `status = "down"` | + +--- + +## Probes + +| Dependency | Probe method | Healthy signal | +|---|---|---| +| `database` | `SELECT 1` against the Postgres connection pool | Query resolves without error | +| `sorobanRpc` | `getLatestLedger()` against `SOROBAN_RPC_URL` | Response received without error | + +Both probes run in parallel (`Promise.all`). The endpoint is **not** cached — +every request runs fresh probes. If you need caching, add a reverse-proxy or +sidecar cache in front of this path. + +--- + +## HTTP status codes + +| Code | Meaning | +|---|---| +| `200` | All dependency probes passed. | +| `503` | At least one dependency probe failed. The response body names which one. | +| `500` | An unexpected error was thrown inside a probe (not a graceful failure). Check logs with the `correlationId`. | + +--- + +## Structured log events + +Every probe run emits a `pino` log entry at level `info`: + +```json +{ + "level": 30, + "correlationId": "…", + "status": "ok", + "httpStatus": 200, + "elapsedMs": 22, + "database": "ok", + "sorobanRpc": "ok", + "msg": "recommendations_health_check_complete" +} +``` + +Unexpected errors emit at level `error`: + +```json +{ + "level": 50, + "correlationId": "…", + "err": { … }, + "elapsedMs": 5, + "msg": "recommendations_health_probe_threw" +} +``` + +--- + +## Security + +- No authentication required — the response contains no secrets or user data. +- In production, restrict access at the infrastructure level (internal ALB + rule, VPC-only routing, service-mesh policy, etc.) so external clients + cannot reach this path. + +--- + +## Related endpoints + +| Endpoint | Description | +|---|---| +| `GET /health` | Liveness check — no I/O | +| `GET /healthz/dependencies` | Shallow cached probe (all 4 deps, 5 s TTL) | +| `GET /api/health/ready` | Deep readiness for orchestrators | +| `GET /api/predictions/health` | Predictions-subsystem probe (same shape) | +| `GET /api/indexer/health` | Indexer health with cursor lag | diff --git a/src/index.ts b/src/index.ts index 097d3876..34113902 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { versionRouter } from "./routes/health/version"; import { redisConnection } from "./queue"; import { authRouter } from "./routes/auth"; import { recommendationsRouter } from "./routes/recommendations"; +import { recommendationsHealthRouter } from "./routes/recommendations/health"; import { tagsRouter } from "./routes/tags"; import { auditRouter } from "./routes/audit"; import { marketsRouter } from "./routes/markets"; @@ -164,6 +165,7 @@ export function createApp(_options: CreateAppOptions = {}): express.Express { ); app.use("/api/auth", authRouter); + app.use("/api/recommendations/health", recommendationsHealthRouter); app.use("/api/recommendations", recommendationsRouter); app.use("/api/tags", tagsRouter); app.use("/api/audit", auditRouter); diff --git a/src/routes/recommendations/health.ts b/src/routes/recommendations/health.ts new file mode 100644 index 00000000..e9a1ae12 --- /dev/null +++ b/src/routes/recommendations/health.ts @@ -0,0 +1,229 @@ +/** + * recommendations/health.ts + * + * GET /api/recommendations/health + * + * Health probe endpoint for the /api/recommendations subsystem. Reports the + * status of every external dependency that the recommendations pipeline relies + * on: + * + * • database — Postgres (SELECT 1), stores market + prediction data that + * powers personalised market recommendations. + * • sorobanRpc — Soroban RPC (getLatestLedger), used to verify that the + * on-chain market index that recommendations are built on top + * of is reachable and current. + * + * This endpoint is intentionally separate from the broader probes: + * • GET /health — process liveness (no I/O) + * • GET /healthz/dependencies — shallow cached probe (5 s TTL) + * • GET /api/health/ready — deep readiness for orchestrators + * • GET /api/predictions/health — predictions-subsystem probe + * • GET /api/recommendations/health — this file; recommendations-specific + * + * Response codes + * ────────────── + * 200 OK — all dependencies are healthy + * 503 Unavailable — at least one dependency is down + * + * Response shape + * ────────────── + * { + * "status": "ok" | "down", + * "correlationId": "", + * "checkedAt": "", + * "dependencies": { + * "database": { "status": "ok"|"down", "latencyMs": , "error?": "…" }, + * "sorobanRpc": { "status": "ok"|"down", "latencyMs": , "error?": "…" } + * } + * } + * + * Security + * ──────── + * No authentication required — the response contains no sensitive data. + * In production, restrict access at the infrastructure level (internal ALB, + * VPC-only routing, etc.). + * + * The response is NOT cached. Callers that need caching should add a cache + * layer in front of this endpoint. + * + * Injectable dependencies + * ─────────────────────── + * All external I/O is encapsulated in the `RecommendationsHealthRouterDeps` + * callbacks so tests can substitute fully-controlled stubs without touching + * real infrastructure. + */ + +import { Router, Request, Response, NextFunction } from "express"; +import { randomUUID } from "crypto"; +import { rpc } from "@stellar/stellar-sdk"; +import { pool } from "../../db/client"; +import { env } from "../../config/env"; +import { logger } from "../../config/logger"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type ProbeStatus = "ok" | "down"; + +export interface ProbeResult { + status: ProbeStatus; + latencyMs: number; + error?: string; +} + +export interface RecommendationsDependencyHealth { + database: ProbeResult; + sorobanRpc: ProbeResult; +} + +// ── Default probes ──────────────────────────────────────────────────────────── + +/** + * Probes Postgres with a lightweight `SELECT 1` round-trip. + * Returns `{ status: "ok" }` on success and `{ status: "down" }` on failure. + */ +async function defaultProbeDatabase(): Promise { + const start = Date.now(); + try { + await pool.query("SELECT 1"); + return { status: "ok", latencyMs: Date.now() - start }; + } catch { + return { + status: "down", + latencyMs: Date.now() - start, + error: "Database unavailable", + }; + } +} + +/** + * Probes the Soroban RPC server by calling `getLatestLedger`. + * Returns `{ status: "ok" }` on success and `{ status: "down" }` on failure. + */ +async function defaultProbeSorobanRpc(): Promise { + const start = Date.now(); + try { + const server = new rpc.Server(env.SOROBAN_RPC_URL, { + allowHttp: env.SOROBAN_RPC_URL.startsWith("http://"), + }); + await server.getLatestLedger(); + return { status: "ok", latencyMs: Date.now() - start }; + } catch { + return { + status: "down", + latencyMs: Date.now() - start, + error: "Soroban RPC unavailable", + }; + } +} + +// ── Injectable dependency interface ────────────────────────────────────────── + +export type ProbeDatabaseFn = () => Promise; +export type ProbeSorobanRpcFn = () => Promise; + +export interface RecommendationsHealthRouterDeps { + /** + * Override the database probe (tests only). + * Defaults to a `SELECT 1` probe against the production Postgres pool. + */ + probeDatabase?: ProbeDatabaseFn; + /** + * Override the Soroban RPC probe (tests only). + * Defaults to a `getLatestLedger` call against the configured Soroban RPC + * endpoint. + */ + probeSorobanRpc?: ProbeSorobanRpcFn; +} + +// ── Router factory ──────────────────────────────────────────────────────────── + +/** + * Creates the /api/recommendations/health router with injectable probe + * callbacks. + * + * @param deps.probeDatabase - Override the database probe (tests only). + * Defaults to `defaultProbeDatabase`. + * @param deps.probeSorobanRpc - Override the Soroban RPC probe (tests only). + * Defaults to `defaultProbeSorobanRpc`. + */ +export function createRecommendationsHealthRouter( + deps: RecommendationsHealthRouterDeps = {}, +): Router { + const probeDb: ProbeDatabaseFn = deps.probeDatabase ?? defaultProbeDatabase; + const probeRpc: ProbeSorobanRpcFn = + deps.probeSorobanRpc ?? defaultProbeSorobanRpc; + + const router = Router(); + + /** + * GET /health + * + * Runs the database and Soroban RPC probes in parallel and returns the + * recommendations-subsystem health snapshot. + */ + router.get( + "/health", + async (req: Request, res: Response, next: NextFunction) => { + const correlationId = + ((req.headers["x-correlation-id"] as string | undefined) ?? "").trim() || + randomUUID(); + + const requestStart = Date.now(); + + try { + // Run both probes concurrently; a single slow probe does not block the + // other. + const [database, sorobanRpc] = await Promise.all([ + probeDb(), + probeRpc(), + ]); + + const dependencies: RecommendationsDependencyHealth = { + database, + sorobanRpc, + }; + + const allOk = + database.status === "ok" && sorobanRpc.status === "ok"; + const status: ProbeStatus = allOk ? "ok" : "down"; + const httpStatus = allOk ? 200 : 503; + + logger.info( + { + correlationId, + status, + httpStatus, + elapsedMs: Date.now() - requestStart, + database: database.status, + sorobanRpc: sorobanRpc.status, + }, + "recommendations_health_check_complete", + ); + + res.status(httpStatus).json({ + status, + correlationId, + checkedAt: new Date().toISOString(), + dependencies, + }); + } catch (err) { + logger.error( + { + correlationId, + err, + elapsedMs: Date.now() - requestStart, + }, + "recommendations_health_probe_threw", + ); + next(err); + } + }, + ); + + return router; +} + +// ── Default export ──────────────────────────────────────────────────────────── + +/** Production router instance wired into src/index.ts. */ +export const recommendationsHealthRouter = createRecommendationsHealthRouter(); diff --git a/tests/recommendationsHealth.test.ts b/tests/recommendationsHealth.test.ts new file mode 100644 index 00000000..3df90eae --- /dev/null +++ b/tests/recommendationsHealth.test.ts @@ -0,0 +1,444 @@ +/** + * recommendationsHealth.test.ts + * + * Tests for GET /api/recommendations/health. + * + * Strategy + * ──────── + * • The injectable probe callbacks replace all external I/O — no real DB, + * Redis, or network calls are made. + * • The router is mounted on a minimal Express app so tests are isolated + * from the full application bootstrap. + * • The errorHandler is attached so unexpected-throw tests validate the + * standard error envelope format. + * + * Coverage + * ──────── + * • 200 all-ok + * • 503 database down, sorobanRpc ok + * • 503 database ok, sorobanRpc down + * • 503 both dependencies down + * • Response shape: status, correlationId, checkedAt, dependencies + * • Per-dependency latency and error fields + * • correlationId: echo from header / UUID generation fallback / empty string + * • No authentication required + * • Probe errors propagate as 500 via errorHandler + * • Each probe is called exactly once per request + * • Default export wires to production probes + */ + +// ── Env stubs (must precede all src/ imports) ───────────────────────────────── + +process.env.DATABASE_URL = "postgres://test:test@localhost:5432/test"; +process.env.JWT_SECRET = "abcdefghijklmnopqrstuvwxyz123456789012"; +process.env.SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org"; +process.env.HORIZON_URL = "https://horizon-testnet.stellar.org"; +process.env.PREDICTIFY_CONTRACT_ID = "test-contract-id"; +process.env.REDIS_URL = "redis://localhost:6379"; + +// ── Module mocks (must precede dynamic imports) ─────────────────────────────── + +jest.mock("../src/db/client", () => ({ + db: {}, + pool: { query: jest.fn() }, + connectWithRetry: jest.fn(), + closeDb: jest.fn(), + getDb: jest.fn(), + getPool: jest.fn(), + setDbForTests: jest.fn(), +})); + +jest.mock("../src/queue", () => ({ + redisConnection: { ping: jest.fn().mockResolvedValue("PONG") }, + webhookQueue: { add: jest.fn() }, + backupVerificationQueue: { add: jest.fn() }, + reconciliationQueue: { add: jest.fn() }, + marketResolutionQueue: { add: jest.fn() }, + webhookQueueName: "webhook-deliveries", + backupVerificationQueueName: "backup-verification", + reconciliationQueueName: "reconciliation", + marketResolutionQueueName: "market-resolution", +})); + +// ── Imports ─────────────────────────────────────────────────────────────────── + +import request from "supertest"; +import express from "express"; +import { createRecommendationsHealthRouter } from "../src/routes/recommendations/health"; +import { errorHandler } from "../src/middleware/errorHandler"; +import type { ProbeResult } from "../src/routes/recommendations/health"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const DB_OK: ProbeResult = { status: "ok", latencyMs: 3 }; +const RPC_OK: ProbeResult = { status: "ok", latencyMs: 12 }; + +const DB_DOWN: ProbeResult = { + status: "down", + latencyMs: 100, + error: "Database unavailable", +}; +const RPC_DOWN: ProbeResult = { + status: "down", + latencyMs: 5000, + error: "Soroban RPC unavailable", +}; + +// ── App factory ─────────────────────────────────────────────────────────────── + +function makeApp( + probeDatabase: () => Promise, + probeSorobanRpc: () => Promise, +): express.Express { + const app = express(); + app.use(express.json()); + app.use( + "/api/recommendations", + createRecommendationsHealthRouter({ probeDatabase, probeSorobanRpc }), + ); + app.use(errorHandler); + return app; +} + +const URL = "/api/recommendations/health"; + +// ═════════════════════════════════════════════════════════════════════════════ +// HTTP status codes +// ═════════════════════════════════════════════════════════════════════════════ + +describe("HTTP status codes", () => { + it("returns 200 when all dependencies are ok", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ).get(URL); + expect(res.status).toBe(200); + }); + + it("returns 503 when database is down", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_DOWN), + () => Promise.resolve(RPC_OK), + ), + ).get(URL); + expect(res.status).toBe(503); + }); + + it("returns 503 when Soroban RPC is down", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_DOWN), + ), + ).get(URL); + expect(res.status).toBe(503); + }); + + it("returns 503 when both dependencies are down", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_DOWN), + () => Promise.resolve(RPC_DOWN), + ), + ).get(URL); + expect(res.status).toBe(503); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// Response body — status field +// ═════════════════════════════════════════════════════════════════════════════ + +describe("response body — status field", () => { + it("body.status is 'ok' when all dependencies pass", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ).get(URL); + expect(res.body.status).toBe("ok"); + }); + + it("body.status is 'down' when database is down", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_DOWN), + () => Promise.resolve(RPC_OK), + ), + ).get(URL); + expect(res.body.status).toBe("down"); + }); + + it("body.status is 'down' when Soroban RPC is down", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_DOWN), + ), + ).get(URL); + expect(res.body.status).toBe("down"); + }); + + it("body.status is 'down' when both dependencies are down", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_DOWN), + () => Promise.resolve(RPC_DOWN), + ), + ).get(URL); + expect(res.body.status).toBe("down"); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// Response shape +// ═════════════════════════════════════════════════════════════════════════════ + +describe("response shape", () => { + it("includes all required top-level fields", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ).get(URL); + expect(res.body).toHaveProperty("status"); + expect(res.body).toHaveProperty("correlationId"); + expect(res.body).toHaveProperty("checkedAt"); + expect(res.body).toHaveProperty("dependencies"); + }); + + it("dependencies contains database and sorobanRpc keys", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ).get(URL); + expect(res.body.dependencies).toHaveProperty("database"); + expect(res.body.dependencies).toHaveProperty("sorobanRpc"); + }); + + it("each dependency entry contains status and latencyMs", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ).get(URL); + for (const key of ["database", "sorobanRpc"]) { + expect(res.body.dependencies[key]).toHaveProperty("status"); + expect(res.body.dependencies[key]).toHaveProperty("latencyMs"); + } + }); + + it("checkedAt is a valid ISO-8601 timestamp", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ).get(URL); + expect(typeof res.body.checkedAt).toBe("string"); + expect(() => new Date(res.body.checkedAt)).not.toThrow(); + expect(new Date(res.body.checkedAt).getTime()).toBeGreaterThan(0); + }); + + it("reflects per-dependency latency values from the probe", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ).get(URL); + expect(res.body.dependencies.database.latencyMs).toBe(3); + expect(res.body.dependencies.sorobanRpc.latencyMs).toBe(12); + }); + + it("includes error field when database probe is down", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_DOWN), + () => Promise.resolve(RPC_OK), + ), + ).get(URL); + expect(res.body.dependencies.database.error).toBe("Database unavailable"); + }); + + it("includes error field when sorobanRpc probe is down", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_DOWN), + ), + ).get(URL); + expect(res.body.dependencies.sorobanRpc.error).toBe("Soroban RPC unavailable"); + }); + + it("does not expose error field on healthy probes", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ).get(URL); + expect(res.body.dependencies.database).not.toHaveProperty("error"); + expect(res.body.dependencies.sorobanRpc).not.toHaveProperty("error"); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// Correlation ID +// ═════════════════════════════════════════════════════════════════════════════ + +describe("correlationId", () => { + it("echoes the x-correlation-id header when provided", async () => { + const id = "my-trace-id-abc-123"; + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ) + .get(URL) + .set("x-correlation-id", id); + expect(res.body.correlationId).toBe(id); + }); + + it("generates a UUID when x-correlation-id is not provided", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ).get(URL); + expect(res.body.correlationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); + + it("generates a UUID when x-correlation-id is an empty string", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ) + .get(URL) + .set("x-correlation-id", ""); + expect(res.body.correlationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); + + it("generates a UUID when x-correlation-id is whitespace only", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ) + .get(URL) + .set("x-correlation-id", " "); + expect(res.body.correlationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// Authentication / access control +// ═════════════════════════════════════════════════════════════════════════════ + +describe("authentication", () => { + it("does not require an Authorization header", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ).get(URL); + // Must not return 401 or 403 + expect(res.status).toBe(200); + }); + + it("ignores any supplied Authorization header and still returns 200", async () => { + const res = await request( + makeApp( + () => Promise.resolve(DB_OK), + () => Promise.resolve(RPC_OK), + ), + ) + .get(URL) + .set("Authorization", "Bearer some-random-token"); + expect(res.status).toBe(200); + expect(res.body.status).toBe("ok"); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// Error handling +// ═════════════════════════════════════════════════════════════════════════════ + +describe("error handling", () => { + it("returns 500 when probeDatabase throws unexpectedly", async () => { + const throwing = () => Promise.reject(new Error("DB exploded")); + const res = await request( + makeApp(throwing, () => Promise.resolve(RPC_OK)), + ).get(URL); + expect(res.status).toBe(500); + }); + + it("returns 500 when probeSorobanRpc throws unexpectedly", async () => { + const throwing = () => Promise.reject(new Error("RPC exploded")); + const res = await request( + makeApp(() => Promise.resolve(DB_OK), throwing), + ).get(URL); + expect(res.status).toBe(500); + }); + + it("returns 500 when both probes throw unexpectedly", async () => { + const throwingDb = () => Promise.reject(new Error("DB gone")); + const throwingRpc = () => Promise.reject(new Error("RPC gone")); + const res = await request(makeApp(throwingDb, throwingRpc)).get(URL); + expect(res.status).toBe(500); + }); + + it("calls each probe function exactly once per request", async () => { + const probeDb = jest.fn().mockResolvedValue(DB_OK); + const probeRpc = jest.fn().mockResolvedValue(RPC_OK); + await request(makeApp(probeDb, probeRpc)).get(URL); + expect(probeDb).toHaveBeenCalledTimes(1); + expect(probeRpc).toHaveBeenCalledTimes(1); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// Default export wires to production probes +// ═════════════════════════════════════════════════════════════════════════════ + +describe("default router", () => { + it("exports recommendationsHealthRouter as a valid Express router", async () => { + const { recommendationsHealthRouter } = await import( + "../src/routes/recommendations/health" + ); + expect(typeof recommendationsHealthRouter).toBe("function"); + expect( + Array.isArray( + (recommendationsHealthRouter as unknown as { stack: unknown[] }).stack, + ), + ).toBe(true); + }); + + it("createRecommendationsHealthRouter with no args uses production defaults", () => { + const { createRecommendationsHealthRouter } = + require("../src/routes/recommendations/health"); + const router = createRecommendationsHealthRouter(); + expect(typeof router).toBe("function"); + expect(Array.isArray((router as unknown as { stack: unknown[] }).stack)).toBe(true); + }); +});