Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
81 changes: 81 additions & 0 deletions apps/web/src/app/api/health/route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
50 changes: 45 additions & 5 deletions apps/web/src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -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<HealthCheckResponse["checks"]["database"]> => {
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<NextResponse<HealthCheckResponse>> => {
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 },
);
};
14 changes: 14 additions & 0 deletions apps/web/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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"),
},
},
});