diff --git a/src/app/api/version/__tests__/route.test.ts b/src/app/api/version/__tests__/route.test.ts index f21c9561f..fdff3cd98 100644 --- a/src/app/api/version/__tests__/route.test.ts +++ b/src/app/api/version/__tests__/route.test.ts @@ -4,11 +4,17 @@ vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); +const offlineGeoReadyMock = vi.fn(() => false); +vi.mock("@/lib/geo", () => ({ + offlineGeoReady: () => offlineGeoReadyMock(), +})); + import { GET } from "../route"; beforeEach(() => { delete process.env.NEXT_PUBLIC_APP_BUILD_SHA; delete process.env.NEXT_PUBLIC_APP_BUILT_AT; + offlineGeoReadyMock.mockReturnValue(false); }); interface VersionEnvelope { @@ -20,6 +26,7 @@ interface VersionEnvelope { repository: string; changelog: string; docs: string; + offlineGeoEnabled: boolean; }; } @@ -49,4 +56,18 @@ describe("GET /api/version", () => { expect(body.data.buildSha).toBe("abc1234"); expect(body.data.builtAt).toBe("2026-05-08T12:00:00.000Z"); }); + + it("reports offlineGeoEnabled=false when the GeoLite2 databases are absent", async () => { + offlineGeoReadyMock.mockReturnValue(false); + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as VersionEnvelope; + expect(body.data.offlineGeoEnabled).toBe(false); + }); + + it("reports offlineGeoEnabled=true when the GeoLite2 databases are present", async () => { + offlineGeoReadyMock.mockReturnValue(true); + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as VersionEnvelope; + expect(body.data.offlineGeoEnabled).toBe(true); + }); }); diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts index 7a9fc8371..68ed0bc66 100644 --- a/src/app/api/version/route.ts +++ b/src/app/api/version/route.ts @@ -1,16 +1,24 @@ import { apiHandler } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; +import { offlineGeoReady } from "@/lib/geo"; import packageJson from "../../../../package.json"; -export const dynamic = "force-static"; -export const revalidate = false; +// v1.4.27 R5 — `offlineGeoEnabled` reads from the same source the geo +// resolver uses for the runtime fallback gate, so the public state and +// the runtime behaviour cannot disagree. The check hits the filesystem +// (`existsSync` on the `.empty` marker + the City MMDB) which forces +// the route to be dynamic — the previous `force-static` setting would +// have frozen the answer at build time. +export const dynamic = "force-dynamic"; /** * 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). + * Settings → About surface, the footer of every page, the + * "Check for updates" button (which compares against the GHCR API), + * and the admin status-summary row that surfaces the offline-geo + * availability state. * * The build SHA and built-at timestamp come from environment variables * baked at image build time: @@ -21,11 +29,18 @@ export const revalidate = false; * * For local `pnpm dev` neither is set; the route returns `null` and * the UI falls back to "development" wording. + * + * `offlineGeoEnabled` is `true` when the GeoLite2-City MMDB is present + * at the configured `GEOLITE2_DIR` and no `.empty` marker is set. + * When `false` the runtime falls back to the online `ipwho.is` + * provider and the resolver emits a one-shot admin notification on + * first use. */ 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; + const offlineGeoEnabled = offlineGeoReady(); return apiSuccess({ version, @@ -35,5 +50,6 @@ export const GET = apiHandler(async () => { repository: "https://github.com/MBombeck/HealthLog", changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md", docs: "https://docs.healthlog.dev", + offlineGeoEnabled, }); });