test(measurements): cover the unit transform, MCP unit hardening, and a display guard

Add affine-correctness tests for the transform registry (°C→°F absolute vs a
delta that must stay offset-free, lb/in factors, inverse round-trips), a
resolver test for the inbound unit aliases, a preferred-unit entry-form label
test, and MCP cases for converting a lb hint to canonical kg and refusing an
unknown unit. Add the structural guard that trips if any dashboard or insight
surface reintroduces a hardcoded transformed-type unit or value scale. Ship the
diagnose/convert script pair for legacy non-canonical measurement rows.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 00:21:36 +02:00
1 parent 13b88e47f5
commit ddc598afcb
7 files changed
+565

No files matched your search

@@ -0,0 +1,104 @@
/**
* Convert measurement rows stored in a RECOGNISED non-canonical unit to
* canonical SI, in place. Companion to
* `diagnose-noncanonical-measurement-units.ts` — run the diagnose first.
*
* SAFETY:
* - Dry-run by default. Writes ONLY when called with `--execute`.
* - Touches ONLY rows whose stored `unit` differs from the type's canonical
* unit AND resolves through the shared alias resolver (lb/lbs/pound(s),
* in/inch(es), °F, mmol/L). A row whose unit is already canonical, or whose
* unit is unrecognised, is never touched.
* - Each row is rewritten to the canonical `{ value, unit }` the same
* resolver the CSV importer + MCP write path use produces, so the number is
* re-expressed, never re-labelled in place (which would corrupt it).
* - Affected users' measurement caches are busted at the end.
*
* This repairs the storage LABEL + value for the recognised-alias legacy rows
* (the MCP-leak class). It cannot repair a row whose unit was already canonical
* but whose value was a foreign unit — that class is not mechanically
* identifiable (see the diagnose script).
*
* Run (inside the app container):
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts # dry-run
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute # write
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { getUnitForType } from "@/lib/validations/measurement";
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
async function main(): Promise<void> {
const execute = process.argv.includes("--execute");
const groups = await prisma.measurement.groupBy({
by: ["type", "unit"],
_count: { _all: true },
});
const convertibleGroups = groups.filter((g) => {
if (g.unit === null || g.unit === getUnitForType(g.type)) return false;
return resolveToCanonicalUnit(g.type, 1, g.unit) !== null;
});
if (convertibleGroups.length === 0) {
console.log(
"Nothing to convert — no recognised non-canonical units found.",
);
return;
}
const affectedUsers = new Set<string>();
let converted = 0;
for (const g of convertibleGroups) {
const canonical = getUnitForType(g.type);
const rows = await prisma.measurement.findMany({
where: { type: g.type, unit: g.unit },
select: { id: true, userId: true, value: true, unit: true },
});
for (const row of rows) {
const resolved = resolveToCanonicalUnit(
g.type,
row.value,
row.unit as string,
);
if (!resolved) continue; // defensive; group already filtered
converted += 1;
affectedUsers.add(row.userId);
console.log(
` ${execute ? "convert" : "would convert"} ${g.type} ${row.id}: ` +
`${row.value} ${row.unit} -> ${resolved.value} ${resolved.unit}`,
);
if (execute) {
await prisma.measurement.update({
where: { id: row.id },
data: { value: resolved.value, unit: canonical },
});
}
}
}
if (execute) {
for (const userId of affectedUsers) {
invalidateUserMeasurements(userId, { evict: true });
}
console.log(
`\nConverted ${converted} row(s) across ${affectedUsers.size} user(s). ` +
"Rollups recompute on the next read/backfill.",
);
} else {
console.log(
`\nDry run: ${converted} row(s) across ${affectedUsers.size} user(s) ` +
"would be converted. Re-run with --execute to write.",
);
}
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,76 @@
/**
* Read-only diagnostic for measurement rows stored in a NON-canonical unit.
*
* Canonical storage is SI: every write path stamps `getUnitForType(type)`. The
* one historical leak was the MCP `log_measurement` tool, which persisted a
* caller-supplied `unit` verbatim (closed in v1.32.26). A row whose `unit`
* column differs from its type's canonical unit is such a legacy row, and it
* mislabels every display surface that trusts the column.
*
* This groups measurements by `(type, unit)` and prints every group whose
* `unit` is not the canonical unit for its type, flagging whether the stored
* unit is a RECOGNISED alias (mechanically convertible by the companion
* convert script) or UNKNOWN (needs a manual look). Nothing is written.
*
* NOT auto-fixable and deliberately out of scope: a row whose stored unit IS
* already canonical but whose VALUE was a foreign unit (e.g. a lb reading typed
* into the old kg-labelled entry form). Those are indistinguishable from real
* canonical readings; the reporter edits them via the record list.
*
* Run (inside the app container, which has DATABASE_URL):
* pnpm dlx tsx scripts/diagnose-noncanonical-measurement-units.ts
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { getUnitForType } from "@/lib/validations/measurement";
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
async function main(): Promise<void> {
const groups = await prisma.measurement.groupBy({
by: ["type", "unit"],
_count: { _all: true },
});
const mismatches = groups.filter((g) => {
if (g.unit === null) return false;
return g.unit !== getUnitForType(g.type);
});
if (mismatches.length === 0) {
console.log("No non-canonical measurement units found. Storage is clean.");
return;
}
let convertible = 0;
let unknown = 0;
console.log(
`Found ${mismatches.length} non-canonical (type, unit) group(s):\n`,
);
for (const g of mismatches.sort((a, b) => b._count._all - a._count._all)) {
const canonical = getUnitForType(g.type);
// Probe convertibility with a neutral value — only the recognition matters.
const recognised = resolveToCanonicalUnit(g.type, 1, g.unit as string);
const status = recognised ? "CONVERTIBLE" : "UNKNOWN — manual review";
if (recognised) convertible += g._count._all;
else unknown += g._count._all;
console.log(
` ${g.type}: stored="${g.unit}" canonical="${canonical}" ` +
`rows=${g._count._all} [${status}]`,
);
}
console.log(
`\nTotals: ${convertible} row(s) convertible, ${unknown} row(s) unknown.`,
);
console.log(
"Convert the recognised aliases with:\n" +
" pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute",
);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,87 @@
/**
* Structural guard (a tripwire, not a proof) for the metric/imperial unit
* fix (issue #627). The dangerous failure mode is a PARTIAL fix: entry converts
* but a display surface still hardcodes the canonical unit, so an imperial user
* sees converted-then-mislabelled soup. This walks the dashboard + insight page
* sources and asserts none of them passes a transformed type's DISPLAY UNIT
* (kg / lb / cm / in / °C / °F / km/h / mph / km / mi) or a hardcoded value
* scale as a literal prop — those must resolve from the user's preference
* through `useUnitDisplay()` instead.
*
* Mutation check: add `unit="kg"` back to any swept page and this goes red.
*/
import { readFileSync } from "node:fs";
import { globSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
import {
getDisplayTransform,
TRANSFORMED_TYPES,
} from "@/lib/measurements/display-transform";
const SRC = join(process.cwd(), "src");
/** The set of display-unit strings a transformed type can render. */
const TRANSFORMED_UNITS: ReadonlySet<string> = new Set(
[...TRANSFORMED_TYPES].flatMap((type) => [
getDisplayTransform(type, "metric").displayUnit,
getDisplayTransform(type, "imperial").displayUnit,
]),
);
/** Swept surfaces: the dashboard client + every insights sub-page. */
function sweptFiles(): string[] {
return [
join(SRC, "app", "page-client.tsx"),
...globSync("app/insights/**/page.tsx", { cwd: SRC }).map((rel) =>
join(SRC, rel),
),
];
}
describe("unit-preference display guard", () => {
it("sweeps a non-empty set of surfaces", () => {
// Guards against a glob that silently matches nothing (a green void).
expect(sweptFiles().length).toBeGreaterThan(10);
});
it("no swept surface hardcodes a transformed type's display unit", () => {
const offenders: string[] = [];
for (const file of sweptFiles()) {
const src = readFileSync(file, "utf8");
for (const unit of TRANSFORMED_UNITS) {
// Match `unit="kg"` / `yAxisUnit="°C"` exactly (closing quote pinned so
// `unit="kg/m²"` — the excluded BMI unit — never trips it).
for (const prop of ["unit", "yAxisUnit"]) {
if (src.includes(`${prop}="${unit}"`)) {
offenders.push(`${file}: ${prop}="${unit}"`);
}
}
}
}
expect(offenders).toEqual([]);
});
it("no insights page hardcodes a numeric valueScale literal", () => {
// Transformed metrics resolve their scale from the transform; a literal
// `valueScale={3.6}` is the pre-fix hand-rolled scaling that double-scales.
const literal = /valueScale=\{\s*-?\d/;
const offenders = sweptFiles().filter((file) =>
literal.test(readFileSync(file, "utf8")),
);
expect(offenders).toEqual([]);
});
it("every transformed type carries both a metric and an imperial branch", () => {
for (const type of TRANSFORMED_TYPES) {
const metric = getDisplayTransform(type, "metric");
const imperial = getDisplayTransform(type, "imperial");
expect(metric.displayUnit).toBeTruthy();
expect(imperial.displayUnit).toBeTruthy();
expect(Number.isFinite(metric.factor)).toBe(true);
expect(Number.isFinite(imperial.factor)).toBe(true);
}
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.32.26 (issue #627) — the manual entry form labels its value field in the
* user's preferred unit for a type with a metric/imperial transform. A metric
* user sees "kg"; an imperial user sees "lb". The field is the entry boundary
* that converts the typed number back to canonical SI on submit, so the label
* and the stored unit must agree with the preference.
*/
let preference: "metric" | "imperial" = "metric";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
role: "USER",
unitPreference: preference,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementForm } from "../measurement-form";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="WEIGHT" />
</I18nProvider>,
);
}
describe("MeasurementForm — preferred-unit value label", () => {
it("labels the weight field in kg for a metric user", () => {
preference = "metric";
const html = render();
expect(html).toContain("kg");
expect(html).not.toContain("lb");
// The metric placeholder (kg) is shown, not the imperial one.
expect(html).toContain('placeholder="75.5"');
});
it("labels the weight field in lb for an imperial user", () => {
preference = "imperial";
const html = render();
expect(html).toContain("lb");
// The imperial placeholder (lb) is shown.
expect(html).toContain('placeholder="165"');
});
});
+46
View File
@@ -89,6 +89,52 @@ describe("logMcpMeasurement", () => {
);
});
it("converts a lb unit hint to canonical kg before writing", async () => {
const result = await logMcpMeasurement({
userId: "u-1",
type: "WEIGHT",
value: 210,
unit: "lb",
idempotencyKey: "key-lb",
});
expect(result.status).toBe("written");
const data = measurement.create.mock.calls[0][0].data;
expect(data.unit).toBe("kg");
// 210 lb → 95.25 kg (stored canonical, never the caller's lb verbatim).
expect(data.value).toBeCloseTo(210 * 0.45359237, 5);
if (result.status === "written") {
expect(result.measurement.unit).toBe("kg");
}
});
it("refuses an unsupported unit hint without writing", async () => {
const result = await logMcpMeasurement({
userId: "u-1",
type: "WEIGHT",
value: 12,
unit: "stone",
idempotencyKey: "key-stone",
});
expect(result.status).toBe("unsupported_unit");
expect(measurement.create).not.toHaveBeenCalled();
});
it("stamps canonical kg when the unit hint is omitted", async () => {
const result = await logMcpMeasurement({
userId: "u-1",
type: "WEIGHT",
value: 80,
idempotencyKey: "key-none",
});
expect(result.status).toBe("written");
const data = measurement.create.mock.calls[0][0].data;
expect(data.unit).toBe("kg");
expect(data.value).toBe(80);
});
it("refuses a non-capturable / clinical-only type without writing", async () => {
const result = await logMcpMeasurement({
userId: "u-1",
@@ -2,7 +2,11 @@ import { describe, it, expect } from "vitest";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
invertDisplayTransform,
getDisplayTransform,
hasDisplayTransform,
TRANSFORMED_TYPES,
DEFAULT_UNIT_PREFERENCE,
} from "../display-transform";
@@ -65,4 +69,116 @@ describe("display-transform", () => {
getDisplayTransform("WALKING_SPEED", "metric"),
);
});
// ── v1.32.26 — weight-class / length / temperature registry ──
it("converts WEIGHT kg → lb on the imperial branch", () => {
const metric = getDisplayTransform("WEIGHT", "metric");
expect(metric.factor).toBe(1);
expect(metric.displayUnit).toBe("kg");
// Metric is the identity on the number — byte-identical to storage.
expect(applyDisplayTransform(95.25, metric)).toBe(95.25);
const imperial = getDisplayTransform("WEIGHT", "imperial");
expect(imperial.displayUnit).toBe("lb");
// 95.25 kg → 210.0 lb.
expect(applyDisplayTransform(95.25, imperial)).toBeCloseTo(210.0, 1);
});
it("shares the mass branch across every body-mass metric", () => {
for (const type of [
"TOTAL_BODY_WATER",
"BONE_MASS",
"FAT_MASS",
"FAT_FREE_MASS",
"MUSCLE_MASS",
"LEAN_BODY_MASS",
"GRIP_STRENGTH",
]) {
expect(getDisplayTransform(type, "imperial").displayUnit).toBe("lb");
expect(getDisplayTransform(type, "metric").displayUnit).toBe("kg");
// 10 kg → 22.05 lb.
expect(
applyDisplayTransform(10, getDisplayTransform(type, "imperial")),
).toBeCloseTo(22.0462, 3);
}
});
it("converts WAIST_CIRCUMFERENCE cm → in", () => {
const imperial = getDisplayTransform("WAIST_CIRCUMFERENCE", "imperial");
expect(imperial.displayUnit).toBe("in");
// 84 cm → 33.07 in.
expect(applyDisplayTransform(84, imperial)).toBeCloseTo(33.07, 2);
});
it("converts an ABSOLUTE temperature °C → °F affinely (factor + offset)", () => {
const metric = getDisplayTransform("BODY_TEMPERATURE", "metric");
expect(metric.displayUnit).toBe("°C");
expect(metric.offset ?? 0).toBe(0);
const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial");
expect(imperial.displayUnit).toBe("°F");
expect(imperial.factor).toBe(1.8);
expect(imperial.offset).toBe(32);
// 36.6 °C → 97.88 °F (the +32 shift MUST apply to an absolute reading).
expect(applyDisplayTransform(36.6, imperial)).toBeCloseTo(97.88, 2);
// 0 °C → 32 °F (not 0).
expect(applyDisplayTransform(0, imperial)).toBe(32);
});
it("skin + wrist temperature share the affine temperature branch", () => {
for (const type of ["SKIN_TEMPERATURE", "WRIST_TEMPERATURE"]) {
const imperial = getDisplayTransform(type, "imperial");
expect(imperial.displayUnit).toBe("°F");
expect(imperial.offset).toBe(32);
}
});
it("BODY_TEMPERATURE_DEVIATION scales °C→°F WITHOUT the offset (it is a Δ)", () => {
const imperial = getDisplayTransform(
"BODY_TEMPERATURE_DEVIATION",
"imperial",
);
expect(imperial.displayUnit).toBe("°F");
expect(imperial.factor).toBe(1.8);
// A signed deviation is a delta: 1 Δ°C is a 1.8 Δ°F, NEVER 33.8 °F.
expect(imperial.offset ?? 0).toBe(0);
expect(applyDisplayTransform(1, imperial)).toBeCloseTo(1.8, 5);
});
it("the delta helper NEVER applies the affine offset", () => {
const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial");
// Same absolute transform (offset 32), but a delta must be factor-only.
expect(applyDisplayTransform(1, imperial)).toBe(33.8);
expect(applyDisplayTransformDelta(1, imperial)).toBeCloseTo(1.8, 5);
// A 2 °C rise reads as a 3.6 °F rise, not a 35.6 °F reading.
expect(applyDisplayTransformDelta(2, imperial)).toBeCloseTo(3.6, 5);
});
it("inverse round-trips every registered branch (entry ⇄ display)", () => {
for (const type of TRANSFORMED_TYPES) {
for (const pref of ["metric", "imperial"] as const) {
const transform = getDisplayTransform(type, pref);
for (const raw of [0, 1, 36.6, 84, 95.25, 210]) {
const shown = applyDisplayTransform(raw, transform);
expect(invertDisplayTransform(shown, transform)).toBeCloseTo(raw, 6);
}
}
}
});
it("hasDisplayTransform + TRANSFORMED_TYPES agree, and exclude BMI/glucose", () => {
expect(hasDisplayTransform("WEIGHT")).toBe(true);
expect(hasDisplayTransform("BODY_TEMPERATURE")).toBe(true);
expect(hasDisplayTransform("PULSE")).toBe(false);
// Deliberately excluded (own preference / universal unit).
expect(TRANSFORMED_TYPES.has("BLOOD_GLUCOSE")).toBe(false);
expect(TRANSFORMED_TYPES.has("BODY_MASS_INDEX")).toBe(false);
// Every registered type resolves a non-identity or symbol-only metric
// branch, and both branches exist.
for (const type of TRANSFORMED_TYPES) {
expect(getDisplayTransform(type, "metric").displayUnit).toBeTruthy();
expect(getDisplayTransform(type, "imperial").displayUnit).toBeTruthy();
}
});
});
@@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import { resolveToCanonicalUnit } from "../unit-aliases";
describe("resolveToCanonicalUnit", () => {
it("passes the canonical unit through case-insensitively", () => {
expect(resolveToCanonicalUnit("WEIGHT", 80, "kg")).toEqual({
value: 80,
unit: "kg",
});
expect(resolveToCanonicalUnit("WEIGHT", 80, "KG")).toEqual({
value: 80,
unit: "kg",
});
expect(resolveToCanonicalUnit("BLOOD_GLUCOSE", 95, "MG/DL")).toEqual({
value: 95,
unit: "mg/dL",
});
});
it("converts a lb weight to canonical kg (exact factor)", () => {
const out = resolveToCanonicalUnit("WEIGHT", 210, "lb");
expect(out?.unit).toBe("kg");
expect(out?.value).toBeCloseTo(210 * 0.45359237, 6);
// aliases
for (const alias of ["lbs", "pound", "pounds", "LB"]) {
expect(resolveToCanonicalUnit("WEIGHT", 1, alias)?.unit).toBe("kg");
}
});
it("converts lb for every body-mass metric, not just WEIGHT", () => {
for (const type of ["MUSCLE_MASS", "FAT_MASS", "BONE_MASS"]) {
const out = resolveToCanonicalUnit(type, 22.0462, "lb");
expect(out?.unit).toBe("kg");
expect(out?.value).toBeCloseTo(10, 3);
}
});
it("converts waist inches to canonical cm", () => {
const out = resolveToCanonicalUnit("WAIST_CIRCUMFERENCE", 33, "in");
expect(out?.unit).toBe("cm");
expect(out?.value).toBeCloseTo(83.82, 2);
});
it("converts an absolute °F reading to canonical celsius", () => {
const out = resolveToCanonicalUnit("BODY_TEMPERATURE", 98.6, "°F");
expect(out?.unit).toBe("celsius");
expect(out?.value).toBeCloseTo(37, 5);
// an explicit celsius spelling also normalises to the canonical string
expect(resolveToCanonicalUnit("BODY_TEMPERATURE", 37, "°C")).toEqual({
value: 37,
unit: "celsius",
});
});
it("converts glucose mmol/L to canonical mg/dL", () => {
const out = resolveToCanonicalUnit("BLOOD_GLUCOSE", 5.3, "mmol/L");
expect(out?.unit).toBe("mg/dL");
expect(out?.value).toBeCloseTo(5.3 * 18.016, 3);
});
it("refuses an unrecognised or type-mismatched unit (never mis-stores)", () => {
expect(resolveToCanonicalUnit("WEIGHT", 12, "stone")).toBeNull();
expect(resolveToCanonicalUnit("WEIGHT", 12, "°F")).toBeNull();
// lb is not a waist unit
expect(resolveToCanonicalUnit("WAIST_CIRCUMFERENCE", 12, "lb")).toBeNull();
// mmol/L is not a weight unit
expect(resolveToCanonicalUnit("WEIGHT", 12, "mmol/L")).toBeNull();
});
});