From cd344125d5f511e9214dbc2864d02fbdda81bc58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 21:19:28 +0100 Subject: [PATCH] fix(labs): let a text-only marker take the unit of its first number A marker minted from a text result ("SEE NOTE") has an empty unit, and a marker with a reading has its unit locked, so the first number to arrive, a unitless ratio included, was refused against a unit nothing had chosen. While every reading is text the unit has nothing to contradict, so the first number sets it. A marker that already holds a number keeps its unit. Refs #1313 --- src/lib/labs/reading-conversion.ts | 44 ++++- .../lab-text-marker-adopts-unit.test.ts | 185 ++++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 tests/integration/lab-text-marker-adopts-unit.test.ts diff --git a/src/lib/labs/reading-conversion.ts b/src/lib/labs/reading-conversion.ts index 105650d4d..dffab8114 100644 --- a/src/lib/labs/reading-conversion.ts +++ b/src/lib/labs/reading-conversion.ts @@ -52,7 +52,7 @@ export type ReadingInMarkerUnit = | { ok: false; reason: ConversionRefusal }; export async function readingInMarkerUnit( - client: Pick, + client: Pick, input: { marker: ReadingMarker; value: number; @@ -64,6 +64,18 @@ export async function readingInMarkerUnit( ): Promise { const { marker, value, unit, sourceReferenceLow, sourceReferenceHigh } = input; + if ( + unit !== undefined && + (await adoptFirstNumericUnit(client, marker, unit)) + ) { + return { + ok: true, + converted: false, + value, + sourceReferenceLow, + sourceReferenceHigh, + }; + } if (unit === undefined || sameLabUnit(unit, marker.unit)) { return { ok: true, @@ -103,3 +115,33 @@ export async function readingInMarkerUnit( sourceUnit: unit.trim(), }; } + +/** + * A marker minted from a text result ("SEE NOTE", "negative") has no unit, + * and once it holds a reading its unit is locked, so the first number to + * arrive was refused against an empty unit nothing had ever chosen. While + * every reading the marker holds is text there is no number the unit could + * contradict, so the first numeric reading gives the marker its unit, the + * dimensionless "1" included. The update only matches a marker whose unit is + * still empty, so two readings racing for it cannot both win. `marker.unit` + * is set on the caller's object, which writes the reading under it. + */ +async function adoptFirstNumericUnit( + client: Pick, + marker: ReadingMarker, + unit: string, +): Promise { + const stated = unit.trim(); + if (marker.unit.trim() !== "" || stated === "") return false; + const numeric = await client.labResult.count({ + where: { biomarkerId: marker.id, value: { not: null } }, + }); + if (numeric > 0) return false; + const { count } = await client.biomarker.updateMany({ + where: { id: marker.id, unit: "" }, + data: { unit: stated }, + }); + if (count === 0) return false; + marker.unit = stated; + return true; +} diff --git a/tests/integration/lab-text-marker-adopts-unit.test.ts b/tests/integration/lab-text-marker-adopts-unit.test.ts new file mode 100644 index 000000000..e678f1df1 --- /dev/null +++ b/tests/integration/lab-text-marker-adopts-unit.test.ts @@ -0,0 +1,185 @@ +/** + * A marker minted from a text result takes the unit of its first number. + * + * A report that prints "SEE NOTE" for a ratio mints a marker with an empty + * unit, and a marker with a reading has its unit locked. The next report + * printed the ratio as a number with no unit, and the save was refused + * against a unit nothing had ever chosen. While every reading is text there + * is nothing the unit could contradict, so the first number sets it. Once a + * number is stored, the unit is locked as before. + * + * Through the real `POST /api/labs` against Postgres. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +const prisma = getPrismaClient(); +const USER = "text-marker-unit-user"; + +beforeEach(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER, + username: USER, + modulePreferencesJson: { labs: true }, + }, + }); + const s = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", s.id); +}); + +async function postReading(body: Record) { + const { POST } = await import("@/app/api/labs/route"); + return POST( + new NextRequest("http://localhost/api/labs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +async function textMarker() { + const marker = await prisma.biomarker.create({ + data: { userId: USER, name: "BUN/Creatinine ratio", unit: "" }, + }); + const first = await postReading({ + biomarkerId: marker.id, + valueText: "SEE NOTE", + takenAt: "2026-09-01T08:00:00.000Z", + }); + expect(first.status).toBe(201); + return marker; +} + +describe("a marker whose readings are all text", () => { + it("takes the dimensionless unit from its first unitless number", async () => { + const marker = await textMarker(); + const res = await postReading({ + biomarkerId: marker.id, + value: 18, + unit: "1", + takenAt: "2026-10-01T08:00:00.000Z", + }); + expect(res.status).toBe(201); + const stored = await prisma.biomarker.findUniqueOrThrow({ + where: { id: marker.id }, + }); + expect(stored.unit).toBe("1"); + const reading = await prisma.labResult.findFirstOrThrow({ + where: { biomarkerId: marker.id, value: { not: null } }, + }); + expect(reading).toMatchObject({ value: 18, unit: "1" }); + }); + + it("takes a real unit from its first number the same way", async () => { + const marker = await textMarker(); + const res = await postReading({ + biomarkerId: marker.id, + value: 0.8, + unit: " mg/dL ", + takenAt: "2026-10-01T08:00:00.000Z", + }); + expect(res.status).toBe(201); + const stored = await prisma.biomarker.findUniqueOrThrow({ + where: { id: marker.id }, + }); + expect(stored.unit).toBe("mg/dL"); + }); + + it("locks the unit once a number is stored", async () => { + const marker = await textMarker(); + expect( + ( + await postReading({ + biomarkerId: marker.id, + value: 18, + unit: "1", + takenAt: "2026-10-01T08:00:00.000Z", + }) + ).status, + ).toBe(201); + const second = await postReading({ + biomarkerId: marker.id, + value: 0.8, + unit: "mg/dL", + takenAt: "2026-10-15T08:00:00.000Z", + }); + expect(second.status).toBe(422); + const stored = await prisma.biomarker.findUniqueOrThrow({ + where: { id: marker.id }, + }); + expect(stored.unit).toBe("1"); + }); + + it("leaves an empty unit alone when the marker already holds a number", async () => { + // Older data: a number stored under a marker with no unit. That number + // is in some unit nobody recorded, so a new unit must not be guessed. + const marker = await prisma.biomarker.create({ + data: { userId: USER, name: "Legacy ratio", unit: "" }, + }); + await prisma.labResult.create({ + data: { + userId: USER, + biomarkerId: marker.id, + analyte: marker.name, + value: 12, + unit: "", + takenAt: new Date("2026-08-01T08:00:00.000Z"), + source: "MANUAL", + }, + }); + const res = await postReading({ + biomarkerId: marker.id, + value: 0.8, + unit: "mg/dL", + takenAt: "2026-10-01T08:00:00.000Z", + }); + expect(res.status).toBe(422); + const stored = await prisma.biomarker.findUniqueOrThrow({ + where: { id: marker.id }, + }); + expect(stored.unit).toBe(""); + }); + + it("leaves a marker with a unit of its own refusing a unitless number", async () => { + const marker = await prisma.biomarker.create({ + data: { userId: USER, name: "Creatinine", unit: "mg/dL" }, + }); + const res = await postReading({ + biomarkerId: marker.id, + value: 18, + unit: "1", + takenAt: "2026-10-01T08:00:00.000Z", + }); + expect(res.status).toBe(422); + const stored = await prisma.biomarker.findUniqueOrThrow({ + where: { id: marker.id }, + }); + expect(stored.unit).toBe("mg/dL"); + }); +});