import { describe, expect, it } from "vitest"; import { createMeasurementSchema, getUnitForType, listMeasurementsSchema, validateMeasurementRange, } from "../measurement"; describe("measurement validation", () => { describe("getUnitForType", () => { it("returns canonical unit for each measurement type", () => { expect(getUnitForType("WEIGHT")).toBe("kg"); expect(getUnitForType("BLOOD_PRESSURE_SYS")).toBe("mmHg"); expect(getUnitForType("BLOOD_PRESSURE_DIA")).toBe("mmHg"); expect(getUnitForType("PULSE")).toBe("bpm"); expect(getUnitForType("BODY_FAT")).toBe("%"); // v1.4.23 — sleep duration shifted from hours to minutes so per-stage // HealthKit category samples can be stored without precision loss. expect(getUnitForType("SLEEP_DURATION")).toBe("minutes"); expect(getUnitForType("ACTIVITY_STEPS")).toBe("steps"); expect(getUnitForType("BLOOD_GLUCOSE")).toBe("mg/dL"); expect(getUnitForType("TOTAL_BODY_WATER")).toBe("kg"); expect(getUnitForType("BONE_MASS")).toBe("kg"); // ── v1.4.23 Apple Health canonical units ── expect(getUnitForType("HEART_RATE_VARIABILITY")).toBe("ms"); expect(getUnitForType("RESTING_HEART_RATE")).toBe("bpm"); expect(getUnitForType("ACTIVE_ENERGY_BURNED")).toBe("kcal"); expect(getUnitForType("FLIGHTS_CLIMBED")).toBe("flights"); expect(getUnitForType("WALKING_RUNNING_DISTANCE")).toBe("m"); expect(getUnitForType("VO2_MAX")).toBe("mL/(kg·min)"); expect(getUnitForType("BODY_TEMPERATURE")).toBe("celsius"); }); it("returns 'unknown' for unrecognised types", () => { expect(getUnitForType("MADE_UP_TYPE")).toBe("unknown"); }); }); describe("validateMeasurementRange", () => { it("rejects values below the plausible minimum", () => { expect(validateMeasurementRange("WEIGHT", 0.5)).toMatch(/between/i); expect(validateMeasurementRange("BONE_MASS", 0.1)).toMatch(/between/i); expect(validateMeasurementRange("TOTAL_BODY_WATER", 1)).toMatch( /between/i, ); }); it("rejects values above the plausible maximum", () => { expect(validateMeasurementRange("WEIGHT", 600)).toMatch(/between/i); expect(validateMeasurementRange("BONE_MASS", 12)).toMatch(/between/i); expect(validateMeasurementRange("TOTAL_BODY_WATER", 200)).toMatch( /between/i, ); }); it("accepts values inside the plausible range", () => { expect(validateMeasurementRange("WEIGHT", 75)).toBeNull(); expect(validateMeasurementRange("BONE_MASS", 3.0)).toBeNull(); expect(validateMeasurementRange("TOTAL_BODY_WATER", 42)).toBeNull(); }); it("returns null for unknown types (no range = no opinion)", () => { expect(validateMeasurementRange("MADE_UP_TYPE", 9999)).toBeNull(); }); }); describe("createMeasurementSchema", () => { const validBase = { value: 75, measuredAt: "2026-04-27T08:00:00.000Z", }; it("accepts a TOTAL_BODY_WATER measurement without glucoseContext", () => { const parsed = createMeasurementSchema.safeParse({ ...validBase, type: "TOTAL_BODY_WATER", value: 42, }); expect(parsed.success).toBe(true); }); it("accepts a BONE_MASS measurement without glucoseContext", () => { const parsed = createMeasurementSchema.safeParse({ ...validBase, type: "BONE_MASS", value: 3.2, }); expect(parsed.success).toBe(true); }); it("rejects out-of-range values for body composition types", () => { const tooMuchWater = createMeasurementSchema.safeParse({ ...validBase, type: "TOTAL_BODY_WATER", value: 500, }); expect(tooMuchWater.success).toBe(false); const tooLittleBone = createMeasurementSchema.safeParse({ ...validBase, type: "BONE_MASS", value: 0.1, }); expect(tooLittleBone.success).toBe(false); }); it("rejects glucoseContext on non-glucose measurements", () => { const parsed = createMeasurementSchema.safeParse({ ...validBase, type: "TOTAL_BODY_WATER", value: 42, glucoseContext: "FASTING", }); expect(parsed.success).toBe(false); }); it("requires glucoseContext on BLOOD_GLUCOSE measurements", () => { const parsed = createMeasurementSchema.safeParse({ ...validBase, type: "BLOOD_GLUCOSE", value: 95, }); expect(parsed.success).toBe(false); }); it("rejects unrecognised measurement types", () => { const parsed = createMeasurementSchema.safeParse({ ...validBase, type: "TBD_NEW_TYPE", value: 1, }); expect(parsed.success).toBe(false); }); // v1.17 W1b — shared `validateEntryInstant` plausibility bound. it("rejects a future-dated measurement", () => { const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); const parsed = createMeasurementSchema.safeParse({ ...validBase, type: "WEIGHT", value: 80, measuredAt: future, }); expect(parsed.success).toBe(false); }); it("accepts a sane backdated measurement", () => { const past = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const parsed = createMeasurementSchema.safeParse({ ...validBase, type: "WEIGHT", value: 80, measuredAt: past, }); expect(parsed.success).toBe(true); }); it("rejects a measurement dated before 1900", () => { const parsed = createMeasurementSchema.safeParse({ ...validBase, type: "WEIGHT", value: 80, measuredAt: "1899-06-01T00:00:00.000Z", }); expect(parsed.success).toBe(false); }); // v1.4.25 W10 reconcile (code-review M4): `deviceType` was a // batch-only field; the single-entry schema silently dropped it. // Now it parses through so the column is populated whether the // client posts one row or many. it("accepts an optional deviceType tag and surfaces it on the parsed value", () => { const parsed = createMeasurementSchema.safeParse({ ...validBase, type: "WEIGHT", value: 82, deviceType: "scale", }); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.deviceType).toBe("scale"); } }); it("accepts a null deviceType so a client can explicitly clear the column", () => { const parsed = createMeasurementSchema.safeParse({ ...validBase, type: "WEIGHT", value: 82, deviceType: null, }); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.deviceType).toBeNull(); } }); it("treats a missing deviceType as undefined (back-compat with pre-W10 clients)", () => { const parsed = createMeasurementSchema.safeParse({ ...validBase, type: "WEIGHT", value: 82, }); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.deviceType).toBeUndefined(); } }); // v1.17.1 — the `stats:`-prefix overwrite contract is deliberately // batch-scoped. The single manual POST must never accept an // `externalId`, which would open a client-controlled overwrite vector // on the `(userId, type, source, externalId)` unique key. The schema // strips it (no passthrough), so the parsed value never carries one. it("does not surface an externalId on the manual create schema (overwrite contract)", () => { const parsed = createMeasurementSchema.safeParse({ ...validBase, type: "WEIGHT", value: 82, externalId: "stats:HKQuantityTypeIdentifierBodyMass:2026-04-27", }); expect(parsed.success).toBe(true); if (parsed.success) { expect( (parsed.data as Record).externalId, ).toBeUndefined(); } }); }); // v1.4.37 W7c — list-view "one row per day" mode for cumulative // types. The schema pins the optional `groupBy` enum + `dayKey` // shape; the route gates each branch on whether the resolved type // is in `CUMULATIVE_HK_TYPES`. Tests below pin the parser surface. describe("listMeasurementsSchema — v1.4.37 W7c groupBy + dayKey", () => { it("accepts an omitted groupBy + dayKey (legacy per-sample list)", () => { const parsed = listMeasurementsSchema.safeParse({}); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.groupBy).toBeUndefined(); expect(parsed.data.dayKey).toBeUndefined(); } }); it("accepts groupBy=day", () => { const parsed = listMeasurementsSchema.safeParse({ type: "ACTIVITY_STEPS", groupBy: "day", }); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.groupBy).toBe("day"); } }); it("rejects an unknown groupBy value", () => { const parsed = listMeasurementsSchema.safeParse({ type: "ACTIVITY_STEPS", groupBy: "week", }); expect(parsed.success).toBe(false); }); it("accepts a well-formed YYYY-MM-DD dayKey", () => { const parsed = listMeasurementsSchema.safeParse({ type: "ACTIVITY_STEPS", dayKey: "2026-05-15", }); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.dayKey).toBe("2026-05-15"); } }); it("rejects a malformed dayKey (DD.MM.YYYY)", () => { const parsed = listMeasurementsSchema.safeParse({ type: "ACTIVITY_STEPS", dayKey: "15.05.2026", }); expect(parsed.success).toBe(false); }); it("rejects a partial dayKey (missing day component)", () => { const parsed = listMeasurementsSchema.safeParse({ type: "ACTIVITY_STEPS", dayKey: "2026-05", }); expect(parsed.success).toBe(false); }); }); describe("listMeasurementsSchema — v1.15.13 sourceEq filter", () => { it("accepts a valid MeasurementSource on sourceEq", () => { const parsed = listMeasurementsSchema.safeParse({ sourceEq: "WITHINGS" }); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.sourceEq).toBe("WITHINGS"); } }); it("leaves sourceEq undefined when omitted", () => { const parsed = listMeasurementsSchema.safeParse({}); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.sourceEq).toBeUndefined(); } }); it("rejects an unknown sourceEq value", () => { const parsed = listMeasurementsSchema.safeParse({ sourceEq: "BOGUS" }); expect(parsed.success).toBe(false); }); it("rejects the rollup opt-in value on sourceEq (distinct from source)", () => { // `rollup` is the rollup-tier opt-in for `source`, NOT a valid // MeasurementSource filter — sourceEq must not accept it. const parsed = listMeasurementsSchema.safeParse({ sourceEq: "rollup" }); expect(parsed.success).toBe(false); }); it("accepts source=rollup and sourceEq together (independent params)", () => { const parsed = listMeasurementsSchema.safeParse({ source: "rollup", sourceEq: "APPLE_HEALTH", }); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.source).toBe("rollup"); expect(parsed.data.sourceEq).toBe("APPLE_HEALTH"); } }); }); describe("listMeasurementsSchema — v1.18.5 value-range filter", () => { it("coerces valueMin / valueMax from query strings", () => { const parsed = listMeasurementsSchema.safeParse({ valueMin: "80", valueMax: "120", }); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.valueMin).toBe(80); expect(parsed.data.valueMax).toBe(120); } }); it("accepts an open-ended min-only range", () => { const parsed = listMeasurementsSchema.safeParse({ valueMin: "100" }); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.valueMin).toBe(100); expect(parsed.data.valueMax).toBeUndefined(); } }); it("accepts an open-ended max-only range", () => { const parsed = listMeasurementsSchema.safeParse({ valueMax: "90" }); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.valueMax).toBe(90); expect(parsed.data.valueMin).toBeUndefined(); } }); it("accepts valueMin == valueMax (exact-value band)", () => { const parsed = listMeasurementsSchema.safeParse({ valueMin: "72", valueMax: "72", }); expect(parsed.success).toBe(true); }); it("rejects an inverted range (valueMin > valueMax) with a 422-shaped issue", () => { const parsed = listMeasurementsSchema.safeParse({ valueMin: "120", valueMax: "80", }); expect(parsed.success).toBe(false); if (!parsed.success) { expect(parsed.error.issues.some((i) => i.path[0] === "valueMin")).toBe( true, ); } }); it("leaves both bounds undefined when omitted", () => { const parsed = listMeasurementsSchema.safeParse({}); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.valueMin).toBeUndefined(); expect(parsed.data.valueMax).toBeUndefined(); } }); }); // v1.4.38 — the per-day drill-down branch always returns at most // 1000 rows (one pathological phone-only stepCount day). Push the // cap into the validator so a caller asking for more sees a 422 // instead of a silent server-side Math.min clamp. describe("listMeasurementsSchema — v1.4.38 dayKey limit ceiling", () => { it("accepts limit=1000 alongside a dayKey", () => { const parsed = listMeasurementsSchema.safeParse({ type: "ACTIVITY_STEPS", dayKey: "2026-05-15", limit: 1000, }); expect(parsed.success).toBe(true); }); it("rejects limit=1001 when dayKey is set (drill-down hard cap)", () => { const parsed = listMeasurementsSchema.safeParse({ type: "ACTIVITY_STEPS", dayKey: "2026-05-15", limit: 1001, }); expect(parsed.success).toBe(false); if (!parsed.success) { expect(parsed.error.issues[0].message).toMatch(/<=\s*1000/); expect(parsed.error.issues[0].path).toEqual(["limit"]); } }); it("accepts limit=5000 when dayKey is omitted (legacy ceiling)", () => { const parsed = listMeasurementsSchema.safeParse({ type: "ACTIVITY_STEPS", limit: 5000, }); expect(parsed.success).toBe(true); }); }); // v1.10.0 — computed scores (WX-C). describe("computed scores", () => { it("canonical unit is 'score' for each computed-score type", () => { expect(getUnitForType("RECOVERY_SCORE")).toBe("score"); expect(getUnitForType("STRESS_SCORE")).toBe("score"); expect(getUnitForType("STRAIN_SCORE")).toBe("score"); }); it("plausibility range is 0..100 for a computed score", () => { expect(validateMeasurementRange("RECOVERY_SCORE", 0)).toBeNull(); expect(validateMeasurementRange("RECOVERY_SCORE", 100)).toBeNull(); expect(validateMeasurementRange("RECOVERY_SCORE", -1)).not.toBeNull(); expect(validateMeasurementRange("RECOVERY_SCORE", 101)).not.toBeNull(); }); it("rejects every non-writable source on a client write", () => { // v1.10.0 QA — the single-POST validates `source` against the // client-writable subset {MANUAL, APPLE_HEALTH}. COMPUTED (server // scores), WITHINGS (the Withings webhook), and IMPORT (the CSV // importer) are all server-owned and must be rejected so a client // cannot forge a row attributed to a source it does not own. for (const source of ["COMPUTED", "WITHINGS", "IMPORT"]) { const parsed = createMeasurementSchema.safeParse({ type: "WEIGHT", value: 80, measuredAt: "2026-06-02T12:00:00Z", source, }); expect(parsed.success, `expected ${source} to be rejected`).toBe(false); if (!parsed.success) { const issue = parsed.error.issues.find((i) => i.path.includes("source"), ); expect(issue, `expected a source-path rejection for ${source}`).toBeDefined(); } } }); it("accepts the client-writable sources MANUAL and APPLE_HEALTH", () => { for (const source of ["MANUAL", "APPLE_HEALTH"]) { const parsed = createMeasurementSchema.safeParse({ type: "WEIGHT", value: 80, measuredAt: "2026-06-02T12:00:00Z", source, }); expect(parsed.success, `expected ${source} to be accepted`).toBe(true); } }); it("defaults source to MANUAL when omitted", () => { const parsed = createMeasurementSchema.safeParse({ type: "WEIGHT", value: 80, measuredAt: "2026-06-02T12:00:00Z", }); expect(parsed.success).toBe(true); if (parsed.success) expect(parsed.data.source).toBe("MANUAL"); }); }); });