Publish the health-score configuration route

/api/auth/me/health-score-config shipped in v1.35.0 with a settings page
in front of it and no entry in the contract at all — no path, no schemas,
nothing. A client had the surface and no way to generate against it. This
is publication only; the route does what it already did.

Both verbs are registered off what the route actually returns rather than
off a neighbour's shape. The asymmetry matters and is now written down: the
request speaks the positive selection, the pillars that should count, while
the response carries both sides resolved plus the recipe version. The column
stores the complement, which is storage and no client's business.

The response's updatedAt is documented as optional because it genuinely is
absent until a selection exists — an account that never chose has nothing to
guard a write against, and a decoder that required the field would break on
that read.

The PATCH 422 is written out rather than reusing the shared base-token
response, because this route has four of them and a client branching on
meta.errorCode needs all four: the breadth refusal with the sentence to show
the person, the validation failure, the unparseable base token, and the
non-JSON body. Naming only the token would have been a contract that omits
the refusal people actually hit.

The route test now drives the real route to produce each of those responses
and asserts the published contract names every code that comes back, so a
response the route can emit but the spec does not describe fails the suite.
That closes the base-token ledger: nine routes emit invalid_base_updated_at
and all nine now document it.
This commit is contained in:
Marc-André Bombeck committed 2026-08-01 11:57:31 +02:00
1 parent 6a69bad05e
commit 7ddcee0445
3 files changed
+460

No files matched your search

@@ -63,6 +63,7 @@ import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import type { ScorePillarId } from "@/lib/analytics/score/types";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -398,3 +399,145 @@ describe("PATCH — envelope and transport", () => {
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
});
});
/**
* v1.35.1 — the route shipped in v1.35.0 with a settings surface in front
* of it and no published contract at all: `grep -c health-score-config
* docs/api/openapi.yaml` returned 0. This block is the second end.
*
* It does not compare the spec against a restatement of the spec. It drives
* the REAL route to produce each response, reads the codes and keys off the
* bytes it actually returns, and asserts the published contract names them.
* A response the route can emit but the contract does not describe fails
* here, which is the failure the release itself did not catch.
*/
describe("the published contract matches what the route serves", () => {
const PATH = "/api/auth/me/health-score-config";
function doc() {
return buildOpenApiDocument() as {
paths?: Record<
string,
Record<string, { responses?: Record<string, { description?: string }> }>
>;
components?: {
schemas?: Record<
string,
{ properties?: Record<string, unknown>; required?: string[] }
>;
};
};
}
/** Every `meta.errorCode` the PATCH route can actually produce. */
async function emittedErrorCodes(): Promise<Map<number, Set<string>>> {
const byStatus = new Map<number, Set<string>>();
const record = async (body: unknown, conflict = false) => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
vi.mocked(prisma.user.updateMany).mockResolvedValue({
count: conflict ? 0 : 1,
} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch(body),
);
const env = (await res.json()) as { meta?: { errorCode?: string } };
if (env.meta?.errorCode) {
const set = byStatus.get(res.status) ?? new Set<string>();
set.add(env.meta.errorCode);
byStatus.set(res.status, set);
}
};
// Too narrow, both reasons.
await record({ pillars: ["ACTIVITY"] });
await record({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] });
// Body that fails validation.
await record({ pillars: ["NOT_A_PILLAR"] });
// Present-but-unparseable base token.
await record({ pillars: ALL_EIGHT, baseUpdatedAt: null });
// Stale base token.
await record(
{ pillars: ALL_EIGHT, baseUpdatedAt: "2026-07-24T08:00:00.000Z" },
true,
);
return byStatus;
}
it("registers both verbs", () => {
const path = doc().paths?.[PATH];
expect(
path,
`${PATH} is not registered in the OpenAPI document`,
).toBeDefined();
expect(Object.keys(path ?? {}).sort()).toEqual(["get", "patch"]);
});
it("names every errorCode the route can emit, on the right status", async () => {
const emitted = await emittedErrorCodes();
const responses = doc().paths?.[PATH]?.patch?.responses ?? {};
// Sanity: the driver really did produce the codes, so a contract that
// named none of them could not pass by the loop never running.
expect([...(emitted.get(422) ?? [])].sort()).toEqual([
"health_score_config.invalid",
"health_score_config.too_narrow",
"invalid_base_updated_at",
]);
expect([...(emitted.get(409) ?? [])]).toEqual([
"health_score_config_conflict",
]);
for (const [status, codes] of emitted) {
const described = responses[String(status)]?.description ?? "";
for (const code of codes) {
expect(
described,
`${status} description does not mention \`${code}\``,
).toContain(code);
}
}
});
it("publishes exactly the fields a real response carries", async () => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT }),
);
const env = (await res.json()) as { data: ResolvedBody };
expect(res.status).toBe(200);
const schema = doc().components?.schemas?.["HealthScoreConfigResponse"];
expect(schema, "HealthScoreConfigResponse is not emitted").toBeDefined();
expect(Object.keys(schema?.properties ?? {}).sort()).toEqual(
Object.keys(env.data).sort(),
);
// `updatedAt` is absent on a never-configured GET, so it must not be
// required — a generated decoder would reject that read.
expect(schema?.required ?? []).not.toContain("updatedAt");
});
it("leaves updatedAt off the read that genuinely has none", async () => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request(`http://localhost${PATH}`),
);
const env = (await res.json()) as { data: ResolvedBody };
const published = Object.keys(
doc().components?.schemas?.["HealthScoreConfigResponse"]?.properties ??
{},
);
expect(Object.keys(env.data)).not.toContain("updatedAt");
for (const key of Object.keys(env.data)) expect(published).toContain(key);
});
});