fix(documents): compare a confirmed lab fact's unit with the shared spelling normaliser

This commit is contained in:
Marc-André Bombeck committed 2026-10-03 23:29:13 +02:00
1 parent 62d47d7056
commit 6c262f2099
2 files changed
+46 -4

No files matched your search

+42 -1
View File
@@ -71,7 +71,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
expect(prisma.labResult.create).not.toHaveBeenCalled();
});
it("commits when the stated unit matches the marker's unit (case/space-insensitive)", async () => {
it("commits when the stated unit is the marker's unit spelled another way", async () => {
vi.mocked(decryptFactData).mockReturnValue({
label: "Glucose",
value: 95,
@@ -103,4 +103,45 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
expect(takenAt.toISOString()).toBe("2025-01-01T12:00:00.000Z");
expect(userDayKey(takenAt, "America/Los_Angeles")).toBe("2025-01-01");
});
// The comparison is the shared spelling normaliser every other lab write
// path uses, not a case fold. Mutation check: put the lower-cased equality
// back → the first case commits (milli read as mega) and the second refuses
// a µg/L reading the other paths accept.
function observation(unit: string, markerUnit: string) {
vi.mocked(decryptFactData).mockReturnValue({
label: "TSH",
value: 2.1,
unit,
referenceLow: null,
referenceHigh: null,
effectiveDate: "2025-01-01",
} as never);
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
id: "bm-2",
name: "TSH",
unit: markerUnit,
lowerBound: null,
upperBound: null,
panel: null,
});
vi.mocked(prisma.labResult.create).mockResolvedValue({
id: "lr-2",
} as never);
}
it("refuses MIU/L against a marker in mIU/L: the capital is mega, not milli", async () => {
observation("MIU/L", "mIU/L");
await expect(
commitApprovedFact("user-1", observationFact()),
).rejects.toMatchObject({ code: "observation.unitMismatch" });
expect(prisma.labResult.create).not.toHaveBeenCalled();
});
it("accepts ug/L against a marker in µg/L, as the other lab write paths do", async () => {
observation("ug/L", "µg/L");
await expect(
commitApprovedFact("user-1", observationFact()),
).resolves.toEqual({ recordType: "labResult", recordId: "lr-2" });
});
});
+4 -3
View File
@@ -28,6 +28,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { decryptFactData } from "@/lib/documents/store";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import {
isUnreadableRange,
parseReferenceRange,
@@ -134,10 +135,10 @@ async function commitObservation(
// the stated number under the catalog's unit would corrupt the reading AND
// break the "transcribe verbatim" contract — so reject the fact and let the
// user reconcile on the review screen, exactly like the `unitRequired` gate.
// Spelling is compared through the shared normaliser, the same one the
// manual and scan paths use: `ug/L` is `µg/L`, but `MIU/L` is not `mIU/L`.
if (!isQualitative) {
const stated = (data.unit as string).trim().toLowerCase();
const target = biomarker.unit.trim().toLowerCase();
if (stated !== target) {
if (!sameLabUnit(data.unit as string, biomarker.unit)) {
throw new FactCommitError(
"observation.unitMismatch",
`The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`,