diff --git a/apps/web/package.json b/apps/web/package.json index a69992b..1c2d890 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,9 @@ "start": "next start", "lint": "eslint --max-warnings 0", "lint:fix": "eslint --fix --max-warnings 0", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@aws-amplify/adapter-nextjs": "^1.7.2", @@ -36,6 +38,7 @@ "@types/react-dom": "19.2.2", "eslint": "^9.39.1", "tailwindcss": "^4.1.18", - "typescript": "5.9.2" + "typescript": "5.9.2", + "vitest": "^3.2.4" } } diff --git a/apps/web/src/app/api/health/route.test.ts b/apps/web/src/app/api/health/route.test.ts new file mode 100644 index 0000000..997dcce --- /dev/null +++ b/apps/web/src/app/api/health/route.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@onecli/db", () => ({ + db: { + $queryRaw: vi.fn(), + }, +})); + +// Dynamic import after mock registration +const importRoute = () => import("./route"); + +describe("GET /api/health", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("returns 200 with ok status when database is healthy", async () => { + const { db } = await import("@onecli/db"); + vi.mocked(db.$queryRaw).mockResolvedValueOnce([{ "?column?": 1 }]); + + const { GET } = await importRoute(); + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.status).toBe("ok"); + expect(body.checks.database.status).toBe("ok"); + expect(typeof body.checks.database.latencyMs).toBe("number"); + expect(typeof body.timestamp).toBe("string"); + }); + + it("returns 503 with degraded status when database is unreachable", async () => { + const { db } = await import("@onecli/db"); + vi.mocked(db.$queryRaw).mockRejectedValueOnce(new Error("Connection refused")); + + const { GET } = await importRoute(); + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(503); + expect(body.status).toBe("degraded"); + expect(body.checks.database.status).toBe("error"); + expect(body.checks.database.error).toBe("Connection refused"); + expect(typeof body.checks.database.latencyMs).toBe("number"); + }); + + it("returns 503 with degraded status when database throws unknown error", async () => { + const { db } = await import("@onecli/db"); + vi.mocked(db.$queryRaw).mockRejectedValueOnce("non-error thrown"); + + const { GET } = await importRoute(); + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(503); + expect(body.status).toBe("degraded"); + expect(body.checks.database.error).toBe("Unknown database error"); + }); + + it("response includes a valid ISO 8601 timestamp", async () => { + const { db } = await import("@onecli/db"); + vi.mocked(db.$queryRaw).mockResolvedValueOnce([]); + + const { GET } = await importRoute(); + const response = await GET(); + const body = await response.json(); + + expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp); + }); + + it("database latency is non-negative", async () => { + const { db } = await import("@onecli/db"); + vi.mocked(db.$queryRaw).mockResolvedValueOnce([]); + + const { GET } = await importRoute(); + const response = await GET(); + const body = await response.json(); + + expect(body.checks.database.latencyMs).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index a6c0ca0..f1877e0 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -1,8 +1,48 @@ +import { db } from "@onecli/db"; import { NextResponse } from "next/server"; -export async function GET() { - return NextResponse.json({ - status: "ok", - timestamp: new Date().toISOString(), - }); +type HealthStatus = "ok" | "degraded" | "error"; + +interface HealthCheckResponse { + status: HealthStatus; + timestamp: string; + checks: { + database: { + status: HealthStatus; + latencyMs?: number; + error?: string; + }; + }; } + +const checkDatabase = async (): Promise => { + const start = Date.now(); + try { + await db.$queryRaw`SELECT 1`; + return { status: "ok", latencyMs: Date.now() - start }; + } catch (err) { + return { + status: "error", + latencyMs: Date.now() - start, + error: err instanceof Error ? err.message : "Unknown database error", + }; + } +}; + +export const GET = async (): Promise> => { + const database = await checkDatabase(); + + const overallStatus: HealthStatus = + database.status === "error" ? "degraded" : "ok"; + + const httpStatus = overallStatus === "ok" ? 200 : 503; + + return NextResponse.json( + { + status: overallStatus, + timestamp: new Date().toISOString(), + checks: { database }, + }, + { status: httpStatus }, + ); +}; diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..87e0d82 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +});