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
This commit is contained in:
Marc-André Bombeck committed 2026-11-03 21:19:28 +01:00
1 parent cf7905dc92
commit cd344125d5
2 files changed
+228 -1

No files matched your search

+43 -1
View File
@@ -52,7 +52,7 @@ export type ReadingInMarkerUnit =
| { ok: false; reason: ConversionRefusal };
export async function readingInMarkerUnit(
client: Pick<PrismaClient, "biomarker">,
client: Pick<PrismaClient, "biomarker" | "labResult">,
input: {
marker: ReadingMarker;
value: number;
@@ -64,6 +64,18 @@ export async function readingInMarkerUnit(
): Promise<ReadingInMarkerUnit> {
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<PrismaClient, "biomarker" | "labResult">,
marker: ReadingMarker,
unit: string,
): Promise<boolean> {
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;
}
@@ -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<string, unknown>) {
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");
});
});