diff --git a/src/app/api/measurement-categories/__tests__/route.test.ts b/src/app/api/measurement-categories/__tests__/route.test.ts new file mode 100644 index 000000000..dc99bb6c6 --- /dev/null +++ b/src/app/api/measurement-categories/__tests__/route.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { MEASUREMENT_CATEGORIES } from "@/lib/measurements/categories"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const }, +}; + +function req(): NextRequest { + return new NextRequest("http://localhost/api/measurement-categories", { + method: "GET", + }); +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/measurement-categories", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await GET(req()); + expect(res.status).toBe(401); + }); + + it("returns the categories + assignments envelope to a logged-in user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET(req()); + expect(res.status).toBe(200); + + const body = (await res.json()) as { + data: { + version: number; + categories: Array<{ id: string; labelKey: string; order: number }>; + assignments: Record; + }; + error: null; + }; + + expect(body.error).toBeNull(); + expect(body.data.version).toBe(1); + expect(body.data.categories.length).toBe(8); + // Stable order — vitals first, metabolic last. + expect(body.data.categories[0]).toEqual({ + id: "vitals", + labelKey: "categories.vitals", + order: 0, + }); + expect(body.data.categories.at(-1)).toEqual({ + id: "metabolic", + labelKey: "categories.metabolic", + order: 7, + }); + // Every category carries a `categories.` translation key. + for (const entry of body.data.categories) { + expect(entry.labelKey).toBe(`categories.${entry.id}`); + } + }); + + it("returns an assignments map covering every MeasurementType in the overlay", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET(req()); + expect(res.status).toBe(200); + + const body = (await res.json()) as { + data: { assignments: Record }; + }; + + // Parity with the source map — the endpoint is a thin projection, + // so the assignment count and per-key category must match exactly. + expect(Object.keys(body.data.assignments).length).toBe( + MEASUREMENT_CATEGORIES.size, + ); + for (const [type, category] of MEASUREMENT_CATEGORIES) { + expect(body.data.assignments[type]).toBe(category); + } + }); + + it("sets the 10-minute public cache header", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET(req()); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("public, max-age=600"); + }); +}); diff --git a/src/app/api/measurement-categories/route.ts b/src/app/api/measurement-categories/route.ts new file mode 100644 index 000000000..ef7f55f56 --- /dev/null +++ b/src/app/api/measurement-categories/route.ts @@ -0,0 +1,89 @@ +/** + * `GET /api/measurement-categories` — public categorisation overlay. + * + * Exposes the UI-side `MEASUREMENT_CATEGORIES` map at + * `src/lib/measurements/categories.ts` as an HTTP contract. iOS reads + * this on cold start to drive the HealthKit permission picker + * (one consent screen per category instead of a flat enum list) and + * caches the response client-side; the web Insights nav and the Coach + * evidence shelf will read the same shape post-v1.5. + * + * Response envelope: + * + * { + * "data": { + * "version": 1, + * "categories": [ + * { "id": "vitals", "labelKey": "categories.vitals", "order": 0 }, + * … + * ], + * "assignments": { + * "BLOOD_PRESSURE_SYS": "vitals", + * "WEIGHT": "body", + * … + * } + * }, + * "error": null + * } + * + * - `requireAuth()` — any logged-in user. No admin gate; no PII. + * - `Cache-Control: public, max-age=600` — the overlay is stable across + * a 10-minute window. Clients that need a forced refresh can bypass + * their HTTP cache. + * - `version: 1` is an additive marker; the categorisation shape will + * evolve additively, never by reshuffling existing assignments. + * + * Locked contract per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { + MEASUREMENT_CATEGORIES, + type MeasurementCategory, +} from "@/lib/measurements/categories"; +import { annotate } from "@/lib/logging/context"; + +/** + * Stable category ordering for the iOS picker. Mirrors the + * insertion order of `MEASUREMENT_CATEGORIES` so the UX is identical + * across clients that read the canonical map directly and clients + * that consume this endpoint. + */ +const CATEGORY_ORDER: readonly MeasurementCategory[] = [ + "vitals", + "body", + "activity", + "sleep", + "hearing", + "environment", + "cardiovascular", + "metabolic", +] as const; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const GET = apiHandler(async (_request: NextRequest) => { + await requireAuth(); + + const categories = CATEGORY_ORDER.map((id, order) => ({ + id, + labelKey: `categories.${id}`, + order, + })); + + const assignments: Record = {}; + for (const [type, category] of MEASUREMENT_CATEGORIES) { + assignments[type] = category; + } + + annotate({ action: { name: "measurement-categories.read" } }); + + const response = apiSuccess({ + version: 1 as const, + categories, + assignments, + }); + response.headers.set("Cache-Control", "public, max-age=600"); + return response; +});