mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
chore(release): v1.4.30.1
Categories endpoint + conflict-resolution lock. A two-item follow-up to v1.4.30: the categorisation overlay shipped yesterday as a TypeScript map; the new `GET /api/measurement-categories` endpoint projects it over HTTP so the iOS-side picker can fetch the canonical shape rather than carry a hard-coded mirror — the unblocker the iOS team flagged as the one missing piece. In parallel the SyncMode conflict-resolution policy that the v0.6.0 standalone-first track depends on lands as §13 of `08-locked-contracts.md`.
This commit is contained in:
1 parent
3ece95f192
commit
175e3aea97
8 files changed
+618
-30
No files matched your search
@@ -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<string, string>;
|
||||
};
|
||||
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.<id>` 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<string, string> };
|
||||
};
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, MeasurementCategory> = {};
|
||||
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;
|
||||
});
|
||||
Reference in new issue
Block a user