diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 282ff75..e1bd71e 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,12 +1,43 @@ import { NextResponse } from "next/server"; +import { checkAllDependencies, hasDegradedDependency } from "@/lib/health-checks"; -// GET /api/health — platform liveness (docs/04). DB/queue wired when provisioned. +// GET /api/health — liveness (docs/04, BACKLOG P0-3). +// +// Fixed finding: this used to hardcode `ok: true` and only ever check +// *presence* of DATABASE_URL/REDIS_URL, never whether anything was actually +// reachable -- "an uptime monitor watching HTTP 200 would show green for a +// dying instance" (docs/architecture/ARCHITECTURE.md). Each dependency below +// now reports a real state: "ok" (live round trip succeeded), "error" +// (configured but unreachable -- see `detail`), "not_configured", or +// "not_integrated" (env var present but no client code reads it -- honest +// about `queue`, which has no wired Redis client in this repo). +// +// This route stays a LIVENESS check: it always answers 200 as long as the +// process itself can run the handler (that's what "liveness" means -- an +// external dependency outage is not a reason to restart this instance). The +// body's `ok` field is the honest signal instead: false whenever a +// dependency that IS configured is failing. For a readiness gate that DOES +// fail closed on missing/broken dependencies, see GET /api/ready. export async function GET() { + const deps = await checkAllDependencies(); + const degraded = hasDegradedDependency(deps); + return NextResponse.json({ - ok: true, + ok: !degraded, service: "0pt-web", - db: Boolean(process.env.DATABASE_URL) ? "configured" : "not_configured", - queue: Boolean(process.env.REDIS_URL) ? "configured" : "not_configured", + db: deps.db.status, + queue: deps.queue.status, + engine: deps.engine.status, + stripe: deps.stripe.status, + ...(degraded + ? { + detail: Object.fromEntries( + Object.entries(deps) + .filter(([, v]) => v.status === "error") + .map(([k, v]) => [k, v.detail]), + ), + } + : {}), ts: new Date().toISOString(), }); } diff --git a/app/api/ready/route.ts b/app/api/ready/route.ts new file mode 100644 index 0000000..0c37117 --- /dev/null +++ b/app/api/ready/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { checkAllDependencies, isReady } from "@/lib/health-checks"; + +// GET /api/ready — readiness (BACKLOG P0-3, CD-04/OBS-04: "no /ready route +// existed at all"). +// +// Unlike GET /api/health (liveness -- always 200 while the process can +// respond), this fails closed: 503 whenever a load-bearing dependency isn't +// both configured AND actually reachable. Point a load balancer / Coolify +// readiness probe at this route, not /api/health, to stop routing traffic to +// an instance whose database is down without restarting it. +// +// Today the only load-bearing dependency is the database (see +// `lib/health-checks.ts#isReady`) -- engine/stripe/queue are optional +// integrations 0pt is designed to run without. +export async function GET() { + const deps = await checkAllDependencies(); + const ready = isReady(deps); + + return NextResponse.json( + { + ready, + service: "0pt-web", + db: deps.db.status, + queue: deps.queue.status, + engine: deps.engine.status, + stripe: deps.stripe.status, + ts: new Date().toISOString(), + }, + { status: ready ? 200 : 503 }, + ); +} diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index 4891b0b..08ced4f 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -22,15 +22,35 @@ paths: /api/health: get: operationId: 0pt.health - summary: Liveness + dependency state + summary: Liveness + real per-dependency state x-siax-status: live + security: [] responses: "200": - description: OK — db/queue rapporterar "configured"/"not_configured" + description: Process is alive. Body reports honest per-dependency status (ok/error/not_configured/not_integrated) — see HealthResponse. content: application/json: schema: $ref: "#/components/schemas/HealthResponse" + /api/ready: + get: + operationId: 0pt.ready + summary: Readiness — fails closed when a load-bearing dependency is down + x-siax-status: live + security: [] + responses: + "200": + description: Ready to serve real traffic. + content: + application/json: + schema: + $ref: "#/components/schemas/ReadyResponse" + "503": + description: Not ready — a load-bearing dependency is not configured/reachable. + content: + application/json: + schema: + $ref: "#/components/schemas/ReadyResponse" /api/estimates: post: operationId: 0pt.estimate_order @@ -401,7 +421,18 @@ components: type: string HealthResponse: type: object - required: [ok, service, db, queue, ts] + description: > + db/engine/stripe report a real dependency probe (BACKLOG P0-3): "ok" + means a live round trip succeeded, "error" means it was configured + but unreachable (see `detail`), "not_configured" means the env var + is unset. `queue` can only ever be "not_configured" or + "not_integrated" -- no Redis client is wired in this repo, so + presence of REDIS_URL is reported honestly rather than faking a + connectivity check. `ok` is true only when no configured dependency + is in "error". This is a LIVENESS check: HTTP status stays 200 + whenever the process can respond, regardless of dependency state -- + see /api/ready for the readiness gate that fails closed. + required: [ok, service, db, queue, engine, stripe, ts] properties: ok: type: boolean @@ -409,10 +440,49 @@ components: type: string db: type: string - enum: [configured, not_configured] + enum: [ok, error, not_configured] queue: type: string - enum: [configured, not_configured] + enum: [not_configured, not_integrated] + engine: + type: string + enum: [ok, error, not_configured] + stripe: + type: string + enum: [ok, error, not_configured] + detail: + type: object + description: Present only when `ok` is false -- one-line error message per failing dependency. + additionalProperties: + type: string + ts: + type: string + format: date-time + ReadyResponse: + type: object + description: > + Readiness (BACKLOG P0-3: "no /ready route existed"). Fails closed -- + 503 whenever a load-bearing dependency (today: only `db`) is not + both configured and reachable. Point a load balancer / Coolify + readiness probe here, not at /api/health. + required: [ready, service, db, queue, engine, stripe, ts] + properties: + ready: + type: boolean + service: + type: string + db: + type: string + enum: [ok, error, not_configured] + queue: + type: string + enum: [not_configured, not_integrated] + engine: + type: string + enum: [ok, error, not_configured] + stripe: + type: string + enum: [ok, error, not_configured] ts: type: string format: date-time diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 788ebc0..906e9e3 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -132,6 +132,7 @@ Risknivå = SIAX L0–L5-taxonomin, bedömd av mig utifrån vad handlern gör. | Operation | Metod/sökväg | Risknivå | Kräver godkännande | |---|---|---|---| | Liveness | `GET /api/health` | L0 | Nej (avsiktligt) | +| Readiness | `GET /api/ready` | L0 | Nej (avsiktligt) | | Prisestimat | `POST /api/estimates` | L1 | Nej | | Katalog / källor / fordon | `GET /api/{catalog,sources,vehicles}` | L1 | Nej | | Sanktions-/PEP-screening | `GET /api/screen?q=` | L2 | Nej — proxar mot OpenSanctions | @@ -240,11 +241,20 @@ inget sådant jobb, ingen TTL, ingen `DELETE`-väg och ingen radering finns i ko ## 6. Drift ### Hälsokontroll -`GET /api/health` → `{ok, service:"0pt-web", db, queue, ts}`. Den svarar **alltid -`ok:true`** oavsett om databasen finns — den rapporterar bara `"not_configured"` -i ett fält. En uptime-monitor som tittar på HTTP 200 kommer därför visa grönt -för en instans utan databas, vilket är exakt vad som gäller idag. Engine har ett -separat `GET /health`. +**Fixat (BACKLOG P0-3):** `GET /api/health` → `{ok, service:"0pt-web", db, queue, +engine, stripe, ts}`. `db`/`engine`/`stripe` rapporterar nu en riktig +beroendeprobe (`lib/health-checks.ts`): `"ok"` = lyckad live-anrop, `"error"` = +konfigurerad men onåbar (se `detail`), `"not_configured"` = env-variabel ej +satt. `queue` kan bara vara `"not_configured"`/`"not_integrated"` — ingen +Redis-klient finns i repot, så en satt `REDIS_URL` rapporteras ärligt i stället +för att låtsas testa en anslutning som aldrig görs. `ok` är `true` endast när +ingen konfigurerad dependency ligger i `"error"`. HTTP-statusen är fortsatt +alltid 200 (liveness — processen svarar) oavsett dependency-läge; en extern +beroendekrasch ska inte omstarta instansen. Ny `GET /api/ready` failar stängt +(503) när databasen (den enda lastbärande dependencyn idag) inte både är +konfigurerad och nåbar — peka en Coolify/lastbalanserar-readiness-probe dit, +inte mot `/api/health`. Engine har ett separat `GET /health` som `checkEngine()` +nu faktiskt anropar (tidigare kollades bara `ENGINE_URL`-närvaro). ### Loggar Ingen strukturerad loggning. `console.warn` i `src/auth/context.ts` är den enda diff --git a/docs/slo.md b/docs/slo.md index 5773ef0..407efbb 100644 --- a/docs/slo.md +++ b/docs/slo.md @@ -7,7 +7,8 @@ | SLO | Mål | Nuläge (2026-08-10) | |---|---|---| -| `/api/health` (liveness) | 99,9 % | ✅ 200 (men alltid `ok:true` — readiness saknas, se D2) | +| `/api/health` (liveness) | 99,9 % | ✅ 200; `ok` speglar nu en riktig per-dependency-probe (fixat BACKLOG P0-3) | +| `/api/ready` (readiness) | 99,5 % | ✅ ny (BACKLOG P0-3) — 503 när `db` inte är konfigurerad+nåbar | | Orderflöde `/api/orders` | 99,5 % | ⚠️ fungerar utan DB (in-memory), data försvinner vid restart | | Capability-API `/api/0pt/*` | 99,5 % | ⚠️ live 200 anonymt (lucka S1) — målbild: 401 utan token | | Engine (FastAPI, när deployad) | 99,0 % | ej deployad (EJ VERIFIERAT) | diff --git a/lib/health-checks.ts b/lib/health-checks.ts new file mode 100644 index 0000000..2e737e0 --- /dev/null +++ b/lib/health-checks.ts @@ -0,0 +1,174 @@ +// Dependency health probes shared by GET /api/health (liveness) and +// GET /api/ready (readiness). See docs/architecture/ARCHITECTURE.md +// "Hälsokontroll" and BACKLOG P0-3 for the finding this fixes: the old +// /api/health handler only ever checked *presence* of an env var and +// hardcoded `ok: true` regardless of whether anything was actually +// reachable -- "an uptime monitor watching HTTP 200 would show green for a +// dying instance". Each check below returns one of four honest states: +// +// "ok" -- configured AND a live round trip succeeded. +// "error" -- configured but the round trip failed (bad creds, +// host unreachable, timeout, ...). detail carries the +// underlying error message. +// "not_configured" -- no env var set. Not a failure by itself (0pt is +// designed to build/boot with everything unset). +// "not_integrated" -- env var IS set, but no client code in this repo +// actually uses it (currently only REDIS_URL/queue -- +// see .env.example). Reporting "ok" here would be a +// fabricated check; reporting "not_configured" would +// hide that a value was provided. This is its own +// honest state rather than either of those two. +import { dbUrl, getSql } from "./db"; +import { getStripe } from "./stripe"; +import { hasEngine } from "./engine"; + +export type DepStatus = "ok" | "error" | "not_configured" | "not_integrated"; + +export interface DepResult { + status: DepStatus; + detail?: string; + latencyMs?: number; +} + +export const DEFAULT_TIMEOUT_MS = 2000; + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + promise.then( + (v) => { + clearTimeout(timer); + resolve(v); + }, + (e) => { + clearTimeout(timer); + reject(e); + }, + ); + }); +} + +// Minimal shape of the one call site needs from the postgres client -- +// matches `postgres`'s tagged-template callable, but kept narrow so tests +// can inject a fake without constructing a real `postgres.Sql`. +export type SqlPing = (strings: TemplateStringsArray, ...values: unknown[]) => Promise; + +/** + * Postgres reachability. `select 1` is the standard zero-cost liveness query + * -- it never touches application tables, so it can't fail on schema drift. + */ +export async function checkDatabase( + sqlClient: SqlPing | null = getSql() as unknown as SqlPing | null, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + if (!dbUrl() || !sqlClient) return { status: "not_configured" }; + const start = Date.now(); + try { + await withTimeout(sqlClient`select 1`, timeoutMs, "database ping"); + return { status: "ok", latencyMs: Date.now() - start }; + } catch (err) { + return { status: "error", detail: errMessage(err), latencyMs: Date.now() - start }; + } +} + +/** + * FastAPI data engine (services/api) reachability via its own `GET /health` + * (services/api/orchestrator/main.py). Uses a real HTTP round trip, not just + * `hasEngine()` presence -- ENGINE_URL pointing at a dead host used to read + * as healthy. + */ +export async function checkEngine( + fetchImpl: typeof fetch = fetch, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + if (!hasEngine()) return { status: "not_configured" }; + const base = process.env.ENGINE_URL as string; + const start = Date.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetchImpl(`${base}/health`, { + signal: controller.signal, + cache: "no-store", + headers: { "X-Internal-Token": process.env.INTERNAL_TOKEN ?? "" }, + }); + if (!res.ok) { + return { status: "error", detail: `HTTP ${res.status}`, latencyMs: Date.now() - start }; + } + return { status: "ok", latencyMs: Date.now() - start }; + } catch (err) { + return { status: "error", detail: errMessage(err), latencyMs: Date.now() - start }; + } finally { + clearTimeout(timer); + } +} + +// Minimal shape of the one Stripe call this needs -- matches +// `Stripe#balance.retrieve`, kept narrow so tests can inject a fake instead +// of constructing a real Stripe client. +export interface StripeBalanceClient { + balance: { retrieve(): Promise }; +} + +/** + * Stripe reachability. `balance.retrieve()` is Stripe's documented + * lightweight "is this key valid and can I reach the API" call -- it makes + * one real API round trip (catching e.g. a revoked/placeholder key) without + * mutating anything or touching a specific resource. + */ +export async function checkStripe( + client: StripeBalanceClient | null = getStripe() as unknown as StripeBalanceClient | null, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + if (!client) return { status: "not_configured" }; + const start = Date.now(); + try { + await withTimeout(client.balance.retrieve(), timeoutMs, "stripe balance.retrieve"); + return { status: "ok", latencyMs: Date.now() - start }; + } catch (err) { + return { status: "error", detail: errMessage(err), latencyMs: Date.now() - start }; + } +} + +/** + * Redis/queue: presence-only, honestly. No queue client library is a + * dependency of this package (grep-verified against package.json) and no + * code path in this repo reads REDIS_URL to open a connection -- so there is + * no real check to run. Reporting "ok" would fabricate a probe that doesn't + * exist; this reports "not_integrated" instead of silently upgrading to a + * pretend pass. + */ +export function checkQueue(): DepResult { + return { status: process.env.REDIS_URL ? "not_integrated" : "not_configured" }; +} + +export interface DependencyResults { + db: DepResult; + engine: DepResult; + stripe: DepResult; + queue: DepResult; +} + +export async function checkAllDependencies(): Promise { + const [db, engine, stripe] = await Promise.all([checkDatabase(), checkEngine(), checkStripe()]); + return { db, engine, stripe, queue: checkQueue() }; +} + +/** True when any dependency that IS configured/integrated is broken. */ +export function hasDegradedDependency(deps: DependencyResults): boolean { + return Object.values(deps).some((d) => d.status === "error"); +} + +/** + * Readiness (unlike liveness) treats "not configured" for the deps this app + * cannot serve real traffic without as not-ready too. Only `db` is load-bearing + * today -- engine/stripe/queue are optional integrations the app is designed + * to run without (see .env.example). + */ +export function isReady(deps: DependencyResults): boolean { + return deps.db.status === "ok"; +} diff --git a/middleware.ts b/middleware.ts index acecf6a..990ac9f 100644 --- a/middleware.ts +++ b/middleware.ts @@ -33,7 +33,11 @@ export const config = { matcher: ['/api/0pt/:path*'], }; -const EXEMPT_PATTERNS: RegExp[] = [/^\/api\/health(\/|$)/, /^\/api\/admin\/metrics(\/|$)/]; +const EXEMPT_PATTERNS: RegExp[] = [ + /^\/api\/health(\/|$)/, + /^\/api\/ready(\/|$)/, + /^\/api\/admin\/metrics(\/|$)/, +]; export async function middleware(req: NextRequest): Promise { const path = req.nextUrl.pathname; diff --git a/tests/api/health.test.ts b/tests/api/health.test.ts new file mode 100644 index 0000000..04aaeda --- /dev/null +++ b/tests/api/health.test.ts @@ -0,0 +1,70 @@ +/** + * Route-level regression test for GET /api/health (BACKLOG P0-3): the old + * handler hardcoded `ok: true` regardless of dependency state. These tests + * cover the default (nothing configured) baseline against the real route + * handler; per-dependency ok/error branching is covered at the unit level in + * tests/lib/health-checks.test.ts (dependency-injected, no network/DB + * needed). + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { GET as healthGET } from "@/app/api/health/route"; + +describe("GET /api/health", () => { + const savedEnv = { + DATABASE_URL: process.env.DATABASE_URL, + POSTGRES_URL: process.env.POSTGRES_URL, + ENGINE_URL: process.env.ENGINE_URL, + STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, + REDIS_URL: process.env.REDIS_URL, + }; + + beforeEach(() => { + delete process.env.DATABASE_URL; + delete process.env.POSTGRES_URL; + delete process.env.ENGINE_URL; + delete process.env.STRIPE_SECRET_KEY; + delete process.env.REDIS_URL; + }); + + afterEach(() => { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + it("REGRESSION: always returns HTTP 200 (liveness), but `ok` now reflects a real per-dependency check rather than a hardcoded true", async () => { + const res = await healthGET(); + expect(res.status).toBe(200); + const body = await res.json(); + // With nothing configured, nothing can be degraded -- ok:true here is + // now an honest conclusion, not a hardcoded literal. + expect(body.ok).toBe(true); + expect(body.service).toBe("0pt-web"); + }); + + it("reports not_configured (not the old flat 'configured'/'not_configured' guess) for every unset dependency", async () => { + const res = await healthGET(); + const body = await res.json(); + expect(body.db).toBe("not_configured"); + expect(body.engine).toBe("not_configured"); + expect(body.stripe).toBe("not_configured"); + expect(body.queue).toBe("not_configured"); + }); + + it("reports queue as not_integrated (never a fabricated ok) when REDIS_URL is set but unused", async () => { + process.env.REDIS_URL = "redis://localhost:6379"; + const res = await healthGET(); + const body = await res.json(); + expect(body.queue).toBe("not_integrated"); + // Not wiring a real check for an unused integration is not itself a + // degradation -- overall liveness stays ok. + expect(body.ok).toBe(true); + }); + + it("response includes a ts timestamp", async () => { + const res = await healthGET(); + const body = await res.json(); + expect(new Date(body.ts).toString()).not.toBe("Invalid Date"); + }); +}); diff --git a/tests/api/ready.test.ts b/tests/api/ready.test.ts new file mode 100644 index 0000000..19a5c5b --- /dev/null +++ b/tests/api/ready.test.ts @@ -0,0 +1,34 @@ +/** + * Route-level test for GET /api/ready (BACKLOG P0-3: "no /ready route + * existed at all"). Unlike /api/health, readiness fails closed: 503 when the + * load-bearing dependency (db) is not both configured and reachable. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { GET as readyGET } from "@/app/api/ready/route"; + +describe("GET /api/ready", () => { + const savedEnv = { + DATABASE_URL: process.env.DATABASE_URL, + POSTGRES_URL: process.env.POSTGRES_URL, + }; + + beforeEach(() => { + delete process.env.DATABASE_URL; + delete process.env.POSTGRES_URL; + }); + + afterEach(() => { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + it("fails closed with 503 when the database is not configured", async () => { + const res = await readyGET(); + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.ready).toBe(false); + expect(body.db).toBe("not_configured"); + }); +}); diff --git a/tests/lib/health-checks.test.ts b/tests/lib/health-checks.test.ts new file mode 100644 index 0000000..406b339 --- /dev/null +++ b/tests/lib/health-checks.test.ts @@ -0,0 +1,185 @@ +/** + * Unit tests for the dependency probes behind GET /api/health + GET + * /api/ready (BACKLOG P0-3). Each check is exercised via dependency + * injection (fake sql/fetch/stripe clients) rather than real network I/O, + * matching the fake-over-mock-global convention used elsewhere in this repo + * (e.g. tests/p0/document-intelligence-adapter.test.ts). + */ +import { describe, it, expect, afterEach } from "vitest"; +import { + checkDatabase, + checkEngine, + checkStripe, + checkQueue, + hasDegradedDependency, + isReady, + type DependencyResults, + type SqlPing, +} from "@/lib/health-checks"; + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status < 400, + status, + json: async () => body, + } as unknown as Response; +} + +describe("checkDatabase", () => { + const saved = process.env.DATABASE_URL; + afterEach(() => { + if (saved === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = saved; + }); + + it("reports not_configured when DATABASE_URL is unset, without calling the client", async () => { + delete process.env.DATABASE_URL; + let called = false; + const fakeSql: SqlPing = async () => { + called = true; + return [{ "?column?": 1 }]; + }; + const result = await checkDatabase(fakeSql); + expect(result.status).toBe("not_configured"); + expect(called).toBe(false); + }); + + it("reports ok when the ping round trip succeeds", async () => { + process.env.DATABASE_URL = "postgres://user:pass@localhost:5432/opt"; + const fakeSql: SqlPing = async () => [{ "?column?": 1 }]; + const result = await checkDatabase(fakeSql); + expect(result.status).toBe("ok"); + expect(typeof result.latencyMs).toBe("number"); + }); + + it("reports error with the driver message when the ping fails", async () => { + process.env.DATABASE_URL = "postgres://user:pass@localhost:5432/opt"; + const fakeSql: SqlPing = async () => { + throw new Error("ECONNREFUSED 127.0.0.1:5432"); + }; + const result = await checkDatabase(fakeSql); + expect(result.status).toBe("error"); + expect(result.detail).toContain("ECONNREFUSED"); + }); + + it("reports error on timeout rather than hanging", async () => { + process.env.DATABASE_URL = "postgres://user:pass@localhost:5432/opt"; + const fakeSql: SqlPing = () => new Promise(() => {}); // never resolves + const result = await checkDatabase(fakeSql, 20); + expect(result.status).toBe("error"); + expect(result.detail).toContain("timed out"); + }); + + it("reports not_configured when DATABASE_URL is set but no client is available", async () => { + process.env.DATABASE_URL = "postgres://user:pass@localhost:5432/opt"; + const result = await checkDatabase(null); + expect(result.status).toBe("not_configured"); + }); +}); + +describe("checkEngine", () => { + const saved = process.env.ENGINE_URL; + afterEach(() => { + if (saved === undefined) delete process.env.ENGINE_URL; + else process.env.ENGINE_URL = saved; + }); + + it("reports not_configured when ENGINE_URL is unset", async () => { + delete process.env.ENGINE_URL; + const result = await checkEngine(async () => jsonResponse({ ok: true })); + expect(result.status).toBe("not_configured"); + }); + + it("reports ok when the engine's /health responds 2xx", async () => { + process.env.ENGINE_URL = "http://engine.local:8000"; + const fetchMock = (async (url: string) => { + expect(url).toBe("http://engine.local:8000/health"); + return jsonResponse({ ok: true }); + }) as unknown as typeof fetch; + const result = await checkEngine(fetchMock); + expect(result.status).toBe("ok"); + }); + + it("reports error when the engine responds non-2xx", async () => { + process.env.ENGINE_URL = "http://engine.local:8000"; + const result = await checkEngine(async () => jsonResponse({}, 500)); + expect(result.status).toBe("error"); + expect(result.detail).toContain("500"); + }); + + it("reports error when the fetch throws (host unreachable)", async () => { + process.env.ENGINE_URL = "http://engine.local:8000"; + const result = await checkEngine(async () => { + throw new Error("fetch failed"); + }); + expect(result.status).toBe("error"); + }); +}); + +describe("checkStripe", () => { + it("reports not_configured when no client is available (no STRIPE_SECRET_KEY)", async () => { + const result = await checkStripe(null); + expect(result.status).toBe("not_configured"); + }); + + it("reports ok when balance.retrieve() succeeds", async () => { + const result = await checkStripe({ balance: { retrieve: async () => ({ available: [] }) } }); + expect(result.status).toBe("ok"); + }); + + it("reports error when balance.retrieve() rejects (e.g. revoked key)", async () => { + const result = await checkStripe({ + balance: { + retrieve: async () => { + throw new Error("Invalid API Key provided"); + }, + }, + }); + expect(result.status).toBe("error"); + expect(result.detail).toContain("Invalid API Key"); + }); +}); + +describe("checkQueue", () => { + const saved = process.env.REDIS_URL; + afterEach(() => { + if (saved === undefined) delete process.env.REDIS_URL; + else process.env.REDIS_URL = saved; + }); + + it("never claims ok — only not_configured or not_integrated (no Redis client wired in this repo)", () => { + delete process.env.REDIS_URL; + expect(checkQueue().status).toBe("not_configured"); + process.env.REDIS_URL = "redis://localhost:6379"; + expect(checkQueue().status).toBe("not_integrated"); + }); +}); + +describe("hasDegradedDependency / isReady", () => { + function deps(overrides: Partial = {}): DependencyResults { + return { + db: { status: "not_configured" }, + engine: { status: "not_configured" }, + stripe: { status: "not_configured" }, + queue: { status: "not_configured" }, + ...overrides, + }; + } + + it("hasDegradedDependency is false when nothing is configured", () => { + expect(hasDegradedDependency(deps())).toBe(false); + }); + + it("hasDegradedDependency is true when any dependency errored", () => { + expect(hasDegradedDependency(deps({ engine: { status: "error", detail: "boom" } }))).toBe(true); + }); + + it("isReady is false when db is not ok, even if nothing else is broken", () => { + expect(isReady(deps())).toBe(false); + expect(isReady(deps({ db: { status: "error" } }))).toBe(false); + }); + + it("isReady is true once db is ok, regardless of optional integrations", () => { + expect(isReady(deps({ db: { status: "ok" } }))).toBe(true); + }); +});