diff --git a/src/app/api/version/__tests__/route.test.ts b/src/app/api/version/__tests__/route.test.ts new file mode 100644 index 000000000..f21c9561f --- /dev/null +++ b/src/app/api/version/__tests__/route.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, +})); + +import { GET } from "../route"; + +beforeEach(() => { + delete process.env.NEXT_PUBLIC_APP_BUILD_SHA; + delete process.env.NEXT_PUBLIC_APP_BUILT_AT; +}); + +interface VersionEnvelope { + data: { + version: string; + buildSha: string | null; + builtAt: string | null; + license: string; + repository: string; + changelog: string; + docs: string; + }; +} + +describe("GET /api/version", () => { + it("returns the package.json version", async () => { + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as VersionEnvelope; + expect(body.data.version).toMatch(/^\d+\.\d+\.\d+(-[a-z0-9.-]+)?$/i); + expect(body.data.license).toBe("AGPL-3.0"); + expect(body.data.repository).toContain("github.com"); + expect(body.data.changelog).toContain("CHANGELOG"); + expect(body.data.docs).toContain("docs.healthlog.dev"); + }); + + it("returns null buildSha / builtAt for a `pnpm dev` build", async () => { + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as VersionEnvelope; + expect(body.data.buildSha).toBeNull(); + expect(body.data.builtAt).toBeNull(); + }); + + it("surfaces the build SHA and timestamp when the env vars are set", async () => { + process.env.NEXT_PUBLIC_APP_BUILD_SHA = "abc1234"; + process.env.NEXT_PUBLIC_APP_BUILT_AT = "2026-05-08T12:00:00.000Z"; + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as VersionEnvelope; + expect(body.data.buildSha).toBe("abc1234"); + expect(body.data.builtAt).toBe("2026-05-08T12:00:00.000Z"); + }); +}); diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts new file mode 100644 index 000000000..7a9fc8371 --- /dev/null +++ b/src/app/api/version/route.ts @@ -0,0 +1,39 @@ +import { apiHandler } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import packageJson from "../../../../package.json"; + +export const dynamic = "force-static"; +export const revalidate = false; + +/** + * GET /api/version + * + * Public endpoint exposing the running build's version. Used by the + * Settings → About surface, the footer of every page, and the + * "Check for updates" button (which compares against the GHCR API). + * + * The build SHA and built-at timestamp come from environment variables + * baked at image build time: + * - `NEXT_PUBLIC_APP_BUILD_SHA` — short Git SHA, set by the + * `docker-publish` workflow. + * - `NEXT_PUBLIC_APP_BUILT_AT` — ISO-8601 build timestamp, same + * workflow. + * + * For local `pnpm dev` neither is set; the route returns `null` and + * the UI falls back to "development" wording. + */ +export const GET = apiHandler(async () => { + const version = packageJson.version; + const buildSha = process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null; + const builtAt = process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null; + + return apiSuccess({ + version, + buildSha, + builtAt, + license: "AGPL-3.0", + repository: "https://github.com/MBombeck/HealthLog", + changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md", + docs: "https://docs.healthlog.dev", + }); +});