fix(health): report real per-dependency status instead of hardcoded ok:true #18
+35
-4
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
+75
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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) |
|
||||
|
||||
@@ -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<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
return new Promise<T>((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<unknown>;
|
||||
|
||||
/**
|
||||
* 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<DepResult> {
|
||||
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<DepResult> {
|
||||
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<unknown> };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<DepResult> {
|
||||
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<DependencyResults> {
|
||||
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";
|
||||
}
|
||||
+5
-1
@@ -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<NextResponse> {
|
||||
const path = req.nextUrl.pathname;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user