diff --git a/src/lib/documents/__tests__/commit.test.ts b/src/lib/documents/__tests__/commit.test.ts index 4a54f40d9..5563913eb 100644 --- a/src/lib/documents/__tests__/commit.test.ts +++ b/src/lib/documents/__tests__/commit.test.ts @@ -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" }); + }); }); diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts index 1bc271dfd..4645acc0f 100644 --- a/src/lib/documents/commit.ts +++ b/src/lib/documents/commit.ts @@ -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.`,