mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Field-by-field comparison of the handlers against the registered Zod
schemas across measurements, workouts, medications, daily and auth. The
spec was corrected to match the handlers; no runtime behaviour changed.
Measurements:
- GET /api/measurements returns `{ measurements, meta: { total, limit,
offset, … } }` on all seven of its modes. The spec claimed a top-level
`total` that no arm has ever emitted.
- The list rows are stored rows minus the notes ciphertext, or synthesised
day-sum / sleep-segment rows, or pre-folded aggregate buckets. The
resource declared seven fields; the rest are now on the wire contract.
- POST accepts a bare ARRAY body (the combined blood-pressure write) and
answers with an array, and returns 409 on the dedup index.
- GET/PUT/DELETE /api/measurements/{id} carried no path entry at all.
Workouts: both GET operations run the module gate and answer 403
`module.disabled`; the reusable block existed but was never spread. The
batch ingest stays deliberately ungated and undocumented for it.
Medications: `nextDueAt` / `nextDueOverdue` are computed by the read paths
and absent from the create/update results, so they cannot be required on
the shared shape (the list row keeps them required). `stockUnitsRemaining`
sums a Decimal(12,4) and is fractional, not an integer. The cadence chips
object documented four fields that do not exist and missed the five that
do. `IntakeSource` admits APPLE_HEALTH.
Auth: the token bundle had its optionality inverted — `user` is always
returned, the token pair only for native callers. Adds the undocumented
403 `oidc_only`, the passkey 403/404, the TOTP enrolment 409s, the
attestation 400, the no-key-registered 409, the security-key 404s, the
refresh `revoke` response shape, and the path/query parameters three
operations read but never declared.
Daily was checked and is accurate; so are the batch ingest, series,
series-batch, bulk-delete, restore and by-external-ids routes.
250 lines
7.4 KiB
TypeScript
250 lines
7.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { z } from "zod/v4";
|
|
|
|
import { measurementPaths } from "../routes/measurements";
|
|
import { workoutPaths } from "../routes/workouts";
|
|
|
|
/**
|
|
* Contract guard: the documented request / response shapes must match what the
|
|
* handlers actually put on the wire.
|
|
*
|
|
* The fixtures below are transcribed from the handler return statements, not
|
|
* from the schemas — so a schema that drifts away from the handler fails here
|
|
* rather than shipping a spec the iOS client generates the wrong model from.
|
|
*
|
|
* Sources:
|
|
* - src/app/api/measurements/route.ts — the seven `apiSuccess` returns on GET
|
|
* (every one of them `{ measurements, meta: { total, limit, offset, … } }`),
|
|
* the array-body branch on POST, and the 409 duplicate arm.
|
|
* - src/app/api/workouts/route.ts + src/app/api/workouts/[id]/route.ts —
|
|
* `requireModuleEnabled(user.id, "workouts")`, which answers 403 with
|
|
* `meta.errorCode = "module.disabled"`.
|
|
*/
|
|
|
|
/** Pull the Zod schema out of a registered response entry. */
|
|
function responseSchema(
|
|
paths: typeof measurementPaths,
|
|
path: string,
|
|
method: "get" | "post",
|
|
status: string,
|
|
): z.ZodType {
|
|
const responses = paths[path]?.[method]?.responses as
|
|
| Record<
|
|
string,
|
|
{ content?: Record<string, { schema?: unknown }> } | undefined
|
|
>
|
|
| undefined;
|
|
const schema = responses?.[status]?.content?.["application/json"]?.schema;
|
|
if (!schema) {
|
|
throw new Error(`no ${method.toUpperCase()} ${path} ${status} schema`);
|
|
}
|
|
return schema as z.ZodType;
|
|
}
|
|
|
|
function requestSchema(
|
|
paths: typeof measurementPaths,
|
|
path: string,
|
|
method: "post",
|
|
): z.ZodType {
|
|
const operation = paths[path]?.[method];
|
|
const schema = operation?.requestBody?.content?.["application/json"]?.schema;
|
|
if (!schema)
|
|
throw new Error(`no ${method.toUpperCase()} ${path} body schema`);
|
|
return schema as z.ZodType;
|
|
}
|
|
|
|
const listResponse = responseSchema(
|
|
measurementPaths,
|
|
"/api/measurements",
|
|
"get",
|
|
"200",
|
|
);
|
|
|
|
describe("GET /api/measurements — documented response matches the handler", () => {
|
|
it("accepts the default paged envelope (route.ts `{ measurements, meta }`)", () => {
|
|
const wire = {
|
|
data: {
|
|
measurements: [
|
|
{
|
|
id: "cm000000000000000000000",
|
|
userId: "cm111111111111111111111",
|
|
type: "WEIGHT",
|
|
value: 80.5,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: "2026-07-19T08:00:00.000Z",
|
|
notes: null,
|
|
valueMin: null,
|
|
valueMax: null,
|
|
externalId: null,
|
|
externalSourceVersion: null,
|
|
glucoseContext: null,
|
|
sleepStage: null,
|
|
rhythmClassification: null,
|
|
deviceType: null,
|
|
syncVersion: 1,
|
|
deletedAt: null,
|
|
createdAt: "2026-07-19T08:00:00.000Z",
|
|
updatedAt: "2026-07-19T08:00:00.000Z",
|
|
},
|
|
],
|
|
meta: { total: 1, limit: 50, offset: 0 },
|
|
},
|
|
error: null,
|
|
};
|
|
expect(listResponse.safeParse(wire).success).toBe(true);
|
|
});
|
|
|
|
it("rejects the old `{ measurements, total }` envelope the spec used to claim", () => {
|
|
const stale = {
|
|
data: { measurements: [], total: 0 },
|
|
error: null,
|
|
};
|
|
expect(listResponse.safeParse(stale).success).toBe(false);
|
|
});
|
|
|
|
it("accepts the collapsed day-sum mode (dayKey / sampleCount / partial)", () => {
|
|
const wire = {
|
|
data: {
|
|
measurements: [
|
|
{
|
|
id: "day:ACTIVITY_STEPS:2026-07-18",
|
|
type: "ACTIVITY_STEPS",
|
|
value: 9000,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: "2026-07-18T12:00:00.000Z",
|
|
notes: null,
|
|
dayKey: "2026-07-18",
|
|
sampleCount: 12,
|
|
partial: true,
|
|
},
|
|
],
|
|
meta: {
|
|
total: 1,
|
|
limit: 50,
|
|
offset: 0,
|
|
groupBy: "day",
|
|
droppedDuplicates: 3,
|
|
},
|
|
},
|
|
error: null,
|
|
};
|
|
expect(listResponse.safeParse(wire).success).toBe(true);
|
|
});
|
|
|
|
it("accepts the aggregate mode's pre-folded buckets", () => {
|
|
const wire = {
|
|
data: {
|
|
measurements: [
|
|
{
|
|
type: "PULSE",
|
|
value: 62.5,
|
|
measuredAt: "2026-07-18T00:00:00.000Z",
|
|
count: 240,
|
|
},
|
|
],
|
|
meta: { total: 1, limit: 400, offset: 0, aggregate: "daily" },
|
|
},
|
|
error: null,
|
|
};
|
|
expect(listResponse.safeParse(wire).success).toBe(true);
|
|
});
|
|
|
|
it("accepts the per-night collapse mode", () => {
|
|
const wire = {
|
|
data: {
|
|
measurements: [
|
|
{
|
|
id: "sleep-seg:2026-07-18:0",
|
|
type: "SLEEP_DURATION",
|
|
value: 430,
|
|
unit: "minutes",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: "2026-07-18T06:10:00.000Z",
|
|
sleepStage: "ASLEEP_CORE",
|
|
notes: null,
|
|
},
|
|
],
|
|
meta: { total: 1, limit: 50, offset: 0, groupBy: "night" },
|
|
},
|
|
error: null,
|
|
};
|
|
expect(listResponse.safeParse(wire).success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("POST /api/measurements — documented body + statuses match the handler", () => {
|
|
const body = requestSchema(measurementPaths, "/api/measurements", "post");
|
|
const created = responseSchema(
|
|
measurementPaths,
|
|
"/api/measurements",
|
|
"post",
|
|
"201",
|
|
);
|
|
|
|
const single = {
|
|
type: "PULSE",
|
|
value: 61,
|
|
unit: "bpm",
|
|
measuredAt: "2026-07-19T08:00:00.000Z",
|
|
};
|
|
|
|
it("accepts the single-object body", () => {
|
|
expect(body.safeParse(single).success).toBe(true);
|
|
});
|
|
|
|
it("accepts the ARRAY body the handler branches on (route.ts `Array.isArray(body)`)", () => {
|
|
// The combined blood-pressure write — the reason the array branch exists.
|
|
const systolic = {
|
|
type: "BLOOD_PRESSURE_SYS",
|
|
value: 122,
|
|
unit: "mmHg",
|
|
measuredAt: "2026-07-19T08:00:00.000Z",
|
|
};
|
|
const diastolic = { ...systolic, type: "BLOOD_PRESSURE_DIA", value: 78 };
|
|
expect(body.safeParse([systolic, diastolic]).success).toBe(true);
|
|
});
|
|
|
|
it("accepts an array of created rows on 201, matching the array branch", () => {
|
|
const wire = {
|
|
data: [
|
|
{
|
|
id: "cm000000000000000000000",
|
|
type: "PULSE",
|
|
value: 61,
|
|
unit: "bpm",
|
|
source: "MANUAL",
|
|
measuredAt: "2026-07-19T08:00:00.000Z",
|
|
notes: null,
|
|
},
|
|
],
|
|
error: null,
|
|
};
|
|
expect(created.safeParse(wire).success).toBe(true);
|
|
});
|
|
|
|
it("documents the 409 the duplicate-dedup arm returns", () => {
|
|
const responses = measurementPaths["/api/measurements"]?.post?.responses;
|
|
expect(Object.keys(responses ?? {})).toContain("409");
|
|
});
|
|
});
|
|
|
|
describe("workouts — the module gate's 403 is documented", () => {
|
|
it.each(["/api/workouts", "/api/workouts/{id}"])(
|
|
"GET %s declares 403",
|
|
(path) => {
|
|
const responses = workoutPaths[path]?.get?.responses;
|
|
expect(Object.keys(responses ?? {})).toContain("403");
|
|
expect(responses?.["403"]?.description).toContain("module.disabled");
|
|
},
|
|
);
|
|
|
|
it("leaves the deliberately ungated batch ingest without a module 403", () => {
|
|
// src/app/api/workouts/batch/route.ts runs no `requireModuleEnabled` —
|
|
// synced data still lands while the surface is hidden.
|
|
const responses = workoutPaths["/api/workouts/batch"]?.post?.responses;
|
|
expect(Object.keys(responses ?? {})).not.toContain("403");
|
|
});
|
|
});
|