mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Add an optional numeric value-range filter to the measurement list:
readings whose `value` falls within `[valueMin, valueMax]`, either bound
omittable for an open-ended range. Server-authoritative — both bounds are
parsed on `listMeasurementsSchema` (coerced from the query string), an
inverted `valueMin > valueMax` returns 422, and the bounds thread into the
Prisma `where` as a `value: { gte, lte }` range on the raw per-sample list.
The filter rides a new `FilterBarNumberRange` pill that mirrors the existing
date-range control, hidden for the day-grouped and sleep-night types where
the painted value is a synthesised total rather than a stored reading.
493 lines
17 KiB
TypeScript
493 lines
17 KiB
TypeScript
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<string, unknown>).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");
|
|
});
|
|
});
|
|
});
|