mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The dry run showed the printed range beside a text result, which the write stores on the new marker and not on the reading. The preview and the write now read the reading's window from one helper, and the placeholder marker of a dry run takes its bounds through the same rule as a real mint.
456 lines
13 KiB
TypeScript
456 lines
13 KiB
TypeScript
/**
|
|
* A marker minted from a text result keeps the range the report printed.
|
|
*
|
|
* A report that prints "SEE NOTE" for a ratio, with "10 - 20" in the range
|
|
* column beside it, minted a marker with no bounds: every import path read the
|
|
* range only for a numeric reading. The next report's number then had nothing
|
|
* to be judged against. A numeric range now seeds the new marker the way a
|
|
* numeric first reading does; a range that is text itself ("NEGATIVE") is not
|
|
* a bound and stays out, and a marker that already exists keeps its own.
|
|
*
|
|
* Every path that mints markers from an import, against Postgres: a document's
|
|
* confirmed facts, the lab scan commit, a free-text reading through
|
|
* `POST /api/labs` (the on-device scan posts there), and the CSV import.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { textToStream } from "@/lib/import/csv-stream";
|
|
import { importLabsCsv } from "@/lib/import/labs-csv";
|
|
import { labsCsvQuerySchema } from "@/lib/validations/record-import";
|
|
|
|
vi.mock("@/lib/jobs/boss-instance", () => ({
|
|
getGlobalBoss: vi.fn(() => null),
|
|
}));
|
|
|
|
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-range-user";
|
|
const TAKEN_AT = "2026-09-01T12:00:00.000Z";
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(prisma);
|
|
await prisma.user.create({
|
|
data: {
|
|
id: USER,
|
|
username: USER,
|
|
timezone: "UTC",
|
|
modulePreferencesJson: { inboundDocuments: true, 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);
|
|
});
|
|
|
|
function jsonReq(path: string, body: unknown): NextRequest {
|
|
return new NextRequest(`http://localhost${path}`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function bounds(name: string) {
|
|
const marker = await prisma.biomarker.findFirstOrThrow({
|
|
where: { userId: USER, name },
|
|
});
|
|
return { low: marker.lowerBound, high: marker.upperBound, id: marker.id };
|
|
}
|
|
|
|
async function seedMarker(name: string) {
|
|
return prisma.biomarker.create({
|
|
data: { userId: USER, name, unit: "", lowerBound: 1, upperBound: 2 },
|
|
});
|
|
}
|
|
|
|
// --- document facts ---------------------------------------------------------
|
|
|
|
async function confirmTextFact(
|
|
label: string,
|
|
referenceText: string,
|
|
referenceLow: number | null,
|
|
referenceHigh: number | null,
|
|
) {
|
|
const { encryptFactData, encryptFactProvenance } =
|
|
await import("@/lib/documents/store");
|
|
const doc = await prisma.inboundDocument.create({
|
|
data: {
|
|
userId: USER,
|
|
filename: "report.pdf",
|
|
mimeType: "application/pdf",
|
|
byteSize: 1024,
|
|
contentEncrypted: Buffer.from("stand-in for the stored bytes"),
|
|
status: "EXTRACTED",
|
|
reportDate: new Date(TAKEN_AT),
|
|
},
|
|
});
|
|
const fact = await prisma.extractedFact.create({
|
|
data: {
|
|
documentId: doc.id,
|
|
userId: USER,
|
|
factType: "OBSERVATION",
|
|
status: "PENDING",
|
|
confidence: 0.95,
|
|
needsReview: false,
|
|
dataEncrypted: encryptFactData({
|
|
label,
|
|
code: null,
|
|
codeSystem: null,
|
|
value: null,
|
|
valueText: "SEE NOTE",
|
|
unit: null,
|
|
referenceLow,
|
|
referenceHigh,
|
|
referenceText,
|
|
effectiveDate: null,
|
|
}),
|
|
provenanceEncrypted: encryptFactProvenance({
|
|
sourceText: `${label} SEE NOTE ${referenceText}`,
|
|
anchored: true,
|
|
sourceOffset: 0,
|
|
page: 0,
|
|
confidence: 0.95,
|
|
}),
|
|
},
|
|
});
|
|
const { POST } =
|
|
await import("@/app/api/documents/inbound/[id]/confirm/route");
|
|
const res = await POST(
|
|
jsonReq(`/api/documents/inbound/${doc.id}/confirm`, {
|
|
decisions: [{ factId: fact.id, action: "approve" }],
|
|
}),
|
|
{ params: Promise.resolve({ id: doc.id }) },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.data.failed).toEqual([]);
|
|
}
|
|
|
|
describe("a document's text result", () => {
|
|
it("gives a new marker the numeric range printed beside it", async () => {
|
|
await confirmTextFact("BUN/Creatinine ratio", "10 - 20", null, null);
|
|
expect(await bounds("BUN/Creatinine ratio")).toMatchObject({
|
|
low: 10,
|
|
high: 20,
|
|
});
|
|
});
|
|
|
|
it("does not turn a text range into bounds", async () => {
|
|
await confirmTextFact("Urine nitrite", "NEGATIVE", null, null);
|
|
expect(await bounds("Urine nitrite")).toMatchObject({
|
|
low: null,
|
|
high: null,
|
|
});
|
|
});
|
|
|
|
it("leaves an existing marker's range alone", async () => {
|
|
await seedMarker("BUN/Creatinine ratio");
|
|
await confirmTextFact("BUN/Creatinine ratio", "10 - 20", 10, 20);
|
|
expect(await bounds("BUN/Creatinine ratio")).toMatchObject({
|
|
low: 1,
|
|
high: 2,
|
|
});
|
|
});
|
|
});
|
|
|
|
// --- lab scan commit --------------------------------------------------------
|
|
|
|
async function commitScanRows(rows: Record<string, unknown>[]) {
|
|
const { POST } = await import("@/app/api/labs/ocr/commit/route");
|
|
const res = await POST(
|
|
jsonReq("/api/labs/ocr/commit", {
|
|
rows: rows.map((row) => ({ takenAt: TAKEN_AT, ...row })),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
}
|
|
|
|
describe("a scanned text result", () => {
|
|
it("gives a new marker the confirmed bounds", async () => {
|
|
await commitScanRows([
|
|
{
|
|
analyte: "BUN/Creatinine ratio",
|
|
valueText: "SEE NOTE",
|
|
referenceLow: 10,
|
|
referenceHigh: 20,
|
|
referenceText: "10 - 20",
|
|
},
|
|
]);
|
|
expect(await bounds("BUN/Creatinine ratio")).toMatchObject({
|
|
low: 10,
|
|
high: 20,
|
|
});
|
|
});
|
|
|
|
it("derives them from the printed range when no bounds were sent", async () => {
|
|
await commitScanRows([
|
|
{
|
|
analyte: "BUN/Creatinine ratio",
|
|
valueText: "SEE NOTE",
|
|
referenceText: "10 - 20",
|
|
},
|
|
{ analyte: "Urine nitrite", valueText: "neg", referenceText: "NEGATIVE" },
|
|
]);
|
|
expect(await bounds("BUN/Creatinine ratio")).toMatchObject({
|
|
low: 10,
|
|
high: 20,
|
|
});
|
|
expect(await bounds("Urine nitrite")).toMatchObject({
|
|
low: null,
|
|
high: null,
|
|
});
|
|
});
|
|
|
|
it("drops a transposed window instead of minting it", async () => {
|
|
await commitScanRows([
|
|
{
|
|
analyte: "BUN/Creatinine ratio",
|
|
valueText: "SEE NOTE",
|
|
referenceLow: 20,
|
|
referenceHigh: 10,
|
|
},
|
|
]);
|
|
expect(await bounds("BUN/Creatinine ratio")).toMatchObject({
|
|
low: null,
|
|
high: null,
|
|
});
|
|
});
|
|
|
|
it("leaves an existing marker's range alone", async () => {
|
|
await seedMarker("BUN/Creatinine ratio");
|
|
await commitScanRows([
|
|
{
|
|
analyte: "BUN/Creatinine ratio",
|
|
valueText: "SEE NOTE",
|
|
referenceLow: 10,
|
|
referenceHigh: 20,
|
|
},
|
|
]);
|
|
expect(await bounds("BUN/Creatinine ratio")).toMatchObject({
|
|
low: 1,
|
|
high: 2,
|
|
});
|
|
});
|
|
});
|
|
|
|
// --- POST /api/labs ---------------------------------------------------------
|
|
|
|
async function postLab(body: Record<string, unknown>) {
|
|
const { POST } = await import("@/app/api/labs/route");
|
|
const res = await POST(jsonReq("/api/labs", { takenAt: TAKEN_AT, ...body }));
|
|
expect(res.status).toBe(201);
|
|
return (await res.json()).data;
|
|
}
|
|
|
|
describe("a free-text reading that is a text result", () => {
|
|
it("seeds the new marker, and the next number is judged against it", async () => {
|
|
await postLab({
|
|
analyte: "BUN/Creatinine ratio",
|
|
valueText: "SEE NOTE",
|
|
referenceLow: 10,
|
|
referenceHigh: 20,
|
|
});
|
|
const { id } = await bounds("BUN/Creatinine ratio");
|
|
expect(await bounds("BUN/Creatinine ratio")).toMatchObject({
|
|
low: 10,
|
|
high: 20,
|
|
});
|
|
await postLab({
|
|
biomarkerId: id,
|
|
value: 18,
|
|
unit: "1",
|
|
takenAt: "2026-10-01T12:00:00.000Z",
|
|
});
|
|
const reading = await prisma.labResult.findFirstOrThrow({
|
|
where: { biomarkerId: id, value: { not: null } },
|
|
});
|
|
expect(reading).toMatchObject({ referenceLow: 10, referenceHigh: 20 });
|
|
});
|
|
});
|
|
|
|
describe("a text marker's range and the first unit it adopts", () => {
|
|
it("drops a range stated without a unit when a real unit arrives", async () => {
|
|
// The report printed "30 - 100" beside a pending result, in ng/mL, and
|
|
// said so nowhere: the marker holds the numbers with no unit.
|
|
await postLab({
|
|
analyte: "Vitamin D",
|
|
valueText: "PENDING",
|
|
referenceLow: 30,
|
|
referenceHigh: 100,
|
|
});
|
|
const { id } = await bounds("Vitamin D");
|
|
// The next report states nmol/L. 30 - 100 is not a window in nmol/L.
|
|
await postLab({
|
|
biomarkerId: id,
|
|
value: 60,
|
|
unit: "nmol/L",
|
|
takenAt: "2026-10-01T12:00:00.000Z",
|
|
});
|
|
const marker = await prisma.biomarker.findUniqueOrThrow({ where: { id } });
|
|
expect(marker).toMatchObject({
|
|
unit: "nmol/L",
|
|
lowerBound: null,
|
|
upperBound: null,
|
|
});
|
|
const reading = await prisma.labResult.findFirstOrThrow({
|
|
where: { biomarkerId: id, value: { not: null } },
|
|
});
|
|
expect(reading).toMatchObject({
|
|
unit: "nmol/L",
|
|
referenceLow: null,
|
|
referenceHigh: null,
|
|
});
|
|
});
|
|
|
|
it("takes the window the first number printed in the unit it adopts", async () => {
|
|
await postLab({
|
|
analyte: "Vitamin D",
|
|
valueText: "PENDING",
|
|
referenceLow: 30,
|
|
referenceHigh: 100,
|
|
});
|
|
const { id } = await bounds("Vitamin D");
|
|
await postLab({
|
|
biomarkerId: id,
|
|
value: 60,
|
|
unit: "nmol/L",
|
|
sourceReferenceLow: 75,
|
|
sourceReferenceHigh: 250,
|
|
takenAt: "2026-10-01T12:00:00.000Z",
|
|
});
|
|
expect(await bounds("Vitamin D")).toMatchObject({ low: 75, high: 250 });
|
|
});
|
|
|
|
it("keeps a unitless range when the unit adopted is the unity", async () => {
|
|
await postLab({
|
|
analyte: "BUN/Creatinine ratio",
|
|
valueText: "SEE NOTE",
|
|
referenceLow: 10,
|
|
referenceHigh: 20,
|
|
});
|
|
const { id } = await bounds("BUN/Creatinine ratio");
|
|
await postLab({
|
|
biomarkerId: id,
|
|
value: 18,
|
|
unit: "1",
|
|
sourceReferenceLow: 5,
|
|
sourceReferenceHigh: 30,
|
|
takenAt: "2026-10-01T12:00:00.000Z",
|
|
});
|
|
expect(await bounds("BUN/Creatinine ratio")).toMatchObject({
|
|
low: 10,
|
|
high: 20,
|
|
});
|
|
});
|
|
});
|
|
|
|
// --- CSV import -------------------------------------------------------------
|
|
|
|
describe("a CSV text result", () => {
|
|
const query = (dryRun = false) =>
|
|
labsCsvQuerySchema.parse({
|
|
analyte: "0",
|
|
value: "1",
|
|
unit: "2",
|
|
date: "3",
|
|
referenceRange: "4",
|
|
dryRun: dryRun ? "1" : "0",
|
|
});
|
|
|
|
it("previews a text row the way it writes it", async () => {
|
|
const csv = [
|
|
"Analyte;Value;Unit;Date;Range",
|
|
"BUN/Creatinine ratio;SEE NOTE;;2026-09-01;10 - 20",
|
|
"Sodium;140;mmol/L;2026-09-01;135 - 145",
|
|
].join("\n");
|
|
const preview = await importLabsCsv({
|
|
userId: USER,
|
|
tz: "UTC",
|
|
stream: textToStream(csv),
|
|
maxBytes: 1_000_000,
|
|
query: query(true),
|
|
});
|
|
// The text reading stores no window of its own; the number keeps its.
|
|
expect(preview.preview).toMatchObject([
|
|
{
|
|
marker: "BUN/Creatinine ratio",
|
|
referenceLow: null,
|
|
referenceHigh: null,
|
|
},
|
|
{ marker: "Sodium", referenceLow: 135, referenceHigh: 145 },
|
|
]);
|
|
});
|
|
|
|
it("gives a new marker a numeric range and leaves a text range out", async () => {
|
|
const result = await importLabsCsv({
|
|
userId: USER,
|
|
tz: "UTC",
|
|
stream: textToStream(
|
|
[
|
|
"Analyte;Value;Unit;Date;Range",
|
|
"BUN/Creatinine ratio;SEE NOTE;;2026-09-01;10 - 20",
|
|
"Urine nitrite;neg;;2026-09-01;NEGATIVE",
|
|
].join("\n"),
|
|
),
|
|
maxBytes: 1_000_000,
|
|
query: query(),
|
|
});
|
|
expect(result).toMatchObject({ inserted: 2 });
|
|
expect(await bounds("BUN/Creatinine ratio")).toMatchObject({
|
|
low: 10,
|
|
high: 20,
|
|
});
|
|
expect(await bounds("Urine nitrite")).toMatchObject({
|
|
low: null,
|
|
high: null,
|
|
});
|
|
// The text reading itself still carries no window of its own.
|
|
const reading = await prisma.labResult.findFirstOrThrow({
|
|
where: { userId: USER, analyte: "BUN/Creatinine ratio" },
|
|
});
|
|
expect(reading).toMatchObject({
|
|
valueText: "SEE NOTE",
|
|
sourceReferenceLow: null,
|
|
sourceReferenceHigh: null,
|
|
});
|
|
});
|
|
|
|
it("leaves an existing marker's range alone", async () => {
|
|
await seedMarker("BUN/Creatinine ratio");
|
|
await importLabsCsv({
|
|
userId: USER,
|
|
tz: "UTC",
|
|
stream: textToStream(
|
|
"Analyte;Value;Unit;Date;Range\nBUN/Creatinine ratio;SEE NOTE;;2026-09-01;10 - 20\n",
|
|
),
|
|
maxBytes: 1_000_000,
|
|
query: query(),
|
|
});
|
|
expect(await bounds("BUN/Creatinine ratio")).toMatchObject({
|
|
low: 1,
|
|
high: 2,
|
|
});
|
|
});
|
|
});
|