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:
2026-01-11 08:17:13 +00:00
parent 6a8d55c0b9
commit 6c3dd34412
3 changed files with 483 additions and 24 deletions

View 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);
});
});
});

View 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 },
);
}
}