mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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());
|
||||
Reference in new issue
Block a user