Implement health check endpoint (P2.15)
Add GET /api/health endpoint for deployment monitoring and load balancer health probes. Returns 200 with status "ok" when PocketBase is reachable, 503 with status "unhealthy" when PocketBase connection fails. Response includes timestamp (ISO 8601), version, and error message (on failure). Uses PocketBase SDK's built-in health.check() method for connectivity testing. 14 tests covering healthy/unhealthy states and edge cases. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
197
src/app/api/health/route.test.ts
Normal file
197
src/app/api/health/route.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
// ABOUTME: Tests for health check endpoint used by deployment monitoring and load balancers.
|
||||
// ABOUTME: Covers healthy (200) and unhealthy (503) states based on PocketBase connectivity.
|
||||
|
||||
import type Client from "pocketbase";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock PocketBase before importing the route
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
createPocketBaseClient: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||
import { GET } from "./route";
|
||||
|
||||
const mockCreatePocketBaseClient = vi.mocked(createPocketBaseClient);
|
||||
|
||||
function mockPocketBaseWithHealth(checkFn: ReturnType<typeof vi.fn>): void {
|
||||
const mockPb = {
|
||||
health: { check: checkFn },
|
||||
} as unknown as Client;
|
||||
mockCreatePocketBaseClient.mockReturnValue(mockPb);
|
||||
}
|
||||
|
||||
describe("GET /api/health", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("healthy state", () => {
|
||||
it("returns 200 when PocketBase is reachable", async () => {
|
||||
mockPocketBaseWithHealth(
|
||||
vi.fn().mockResolvedValue({ code: 200, message: "OK" }),
|
||||
);
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns status ok when healthy", async () => {
|
||||
mockPocketBaseWithHealth(
|
||||
vi.fn().mockResolvedValue({ code: 200, message: "OK" }),
|
||||
);
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("includes ISO 8601 timestamp when healthy", async () => {
|
||||
mockPocketBaseWithHealth(
|
||||
vi.fn().mockResolvedValue({ code: 200, message: "OK" }),
|
||||
);
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.timestamp).toBeDefined();
|
||||
// Verify it's a valid ISO 8601 date
|
||||
const date = new Date(body.timestamp);
|
||||
expect(date.toISOString()).toBe(body.timestamp);
|
||||
});
|
||||
|
||||
it("includes version when healthy", async () => {
|
||||
mockPocketBaseWithHealth(
|
||||
vi.fn().mockResolvedValue({ code: 200, message: "OK" }),
|
||||
);
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.version).toBeDefined();
|
||||
expect(typeof body.version).toBe("string");
|
||||
});
|
||||
|
||||
it("does not include error field when healthy", async () => {
|
||||
mockPocketBaseWithHealth(
|
||||
vi.fn().mockResolvedValue({ code: 200, message: "OK" }),
|
||||
);
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unhealthy state", () => {
|
||||
it("returns 503 when PocketBase is unreachable", async () => {
|
||||
mockPocketBaseWithHealth(
|
||||
vi.fn().mockRejectedValue(new Error("Connection refused")),
|
||||
);
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
});
|
||||
|
||||
it("returns status unhealthy when PocketBase fails", async () => {
|
||||
mockPocketBaseWithHealth(
|
||||
vi.fn().mockRejectedValue(new Error("Connection refused")),
|
||||
);
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.status).toBe("unhealthy");
|
||||
});
|
||||
|
||||
it("includes ISO 8601 timestamp when unhealthy", async () => {
|
||||
mockPocketBaseWithHealth(
|
||||
vi.fn().mockRejectedValue(new Error("Connection refused")),
|
||||
);
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.timestamp).toBeDefined();
|
||||
const date = new Date(body.timestamp);
|
||||
expect(date.toISOString()).toBe(body.timestamp);
|
||||
});
|
||||
|
||||
it("includes error message when unhealthy", async () => {
|
||||
mockPocketBaseWithHealth(
|
||||
vi.fn().mockRejectedValue(new Error("Connection refused")),
|
||||
);
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("describes PocketBase failure in error message", async () => {
|
||||
mockPocketBaseWithHealth(
|
||||
vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),
|
||||
);
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.error).toContain("PocketBase");
|
||||
});
|
||||
|
||||
it("does not include version field when unhealthy", async () => {
|
||||
mockPocketBaseWithHealth(
|
||||
vi.fn().mockRejectedValue(new Error("Connection refused")),
|
||||
);
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
// Per spec, version is only in healthy response
|
||||
expect(body.version).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles PocketBase timeout", async () => {
|
||||
mockPocketBaseWithHealth(
|
||||
vi.fn().mockRejectedValue(new Error("timeout of 5000ms exceeded")),
|
||||
);
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe("unhealthy");
|
||||
});
|
||||
|
||||
it("handles PocketBase returning error status code", async () => {
|
||||
mockPocketBaseWithHealth(
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ code: 500, message: "Internal Server Error" }),
|
||||
);
|
||||
|
||||
// PocketBase returning 500 should still be considered healthy from connectivity perspective
|
||||
// as long as the check() call succeeds
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("calls PocketBase health.check exactly once", async () => {
|
||||
const mockCheck = vi.fn().mockResolvedValue({ code: 200, message: "OK" });
|
||||
mockPocketBaseWithHealth(mockCheck);
|
||||
|
||||
await GET();
|
||||
|
||||
expect(mockCheck).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
31
src/app/api/health/route.ts
Normal file
31
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// ABOUTME: Health check endpoint for deployment monitoring and load balancer probes.
|
||||
// ABOUTME: Returns application health status based on PocketBase connectivity.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||
|
||||
const APP_VERSION = "1.0.0";
|
||||
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
try {
|
||||
await pb.health.check();
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
timestamp,
|
||||
version: APP_VERSION,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "unhealthy",
|
||||
timestamp,
|
||||
error: "PocketBase connection failed",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user