mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge branch 'main' into feat/mood-level-a
This commit is contained in:
86 files changed
+4346
-206
No files matched your search
@@ -70,6 +70,14 @@ beforeEach(async () => {
|
||||
username: "cycle-flow",
|
||||
email: "cycle-flow@example.test",
|
||||
gender: "FEMALE",
|
||||
// The suite runs under TZ=UTC and computes its relative day keys with
|
||||
// local Date arithmetic, i.e. in UTC. The route resolves "today" in the
|
||||
// USER's zone and falls back to Europe/Berlin when none is set - one
|
||||
// hour past 22:00 UTC the two clocks disagree about the date and every
|
||||
// day-of-cycle assertion is off by one. Pinning the fixture zone to UTC
|
||||
// makes the test's clock and the route's clock the same clock at any
|
||||
// hour of the day.
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
await loginAs(USER_ID);
|
||||
|
||||
@@ -69,6 +69,14 @@ beforeEach(async () => {
|
||||
username: "cycle-female",
|
||||
email: "cycle-female@example.test",
|
||||
gender: "FEMALE",
|
||||
// The suite runs under TZ=UTC and computes its relative day keys with
|
||||
// local Date arithmetic, i.e. in UTC. The route resolves "today" in the
|
||||
// USER's zone and falls back to Europe/Berlin when none is set - one
|
||||
// hour past 22:00 UTC the two clocks disagree about the date and every
|
||||
// day-of-cycle assertion is off by one. Pinning the fixture zone to UTC
|
||||
// makes the test's clock and the route's clock the same clock at any
|
||||
// hour of the day.
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
await prisma.user.create({
|
||||
@@ -77,6 +85,7 @@ beforeEach(async () => {
|
||||
username: "cycle-male",
|
||||
email: "cycle-male@example.test",
|
||||
gender: "MALE",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* The whole path a printed reference range travels, against a real Postgres.
|
||||
*
|
||||
* The failure this pins is a two-ended one: until now the document reading
|
||||
* pulled the value off a lab report and dropped the range printed beside it,
|
||||
* and every end of that pipe looked healthy on its own. So the assertions here
|
||||
* follow ONE range through the real assembly rather than checking each end in
|
||||
* isolation:
|
||||
*
|
||||
* extract → the staged fact carries the printed string
|
||||
* commit → the written row carries the derived bounds AND the string
|
||||
* read → the API DTO serves both windows and says which is in force
|
||||
* verdict → the reading is judged against the REPORT's window, not the
|
||||
* catalog band — the case where the two disagree is the point
|
||||
* report → the doctor-report payload and the FHIR bundle agree with it
|
||||
*
|
||||
* The catalog band is deliberately set so that the same number reads
|
||||
* "in-range" against the report and "above" against the catalog. A test whose
|
||||
* two windows agree cannot tell whether the precedence rule is wired at all.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER_ID = "user-lab-source-range";
|
||||
|
||||
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);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "lab-source-range",
|
||||
email: "lab-source-range@example.test",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
/** The catalog band the marker carries. 5.2 sits ABOVE this window. */
|
||||
const CATALOG = { lowerBound: 3.5, upperBound: 5.0 };
|
||||
/** The window the report printed. 5.2 sits INSIDE this one. */
|
||||
const PRINTED = "3,9 - 5,4";
|
||||
const VALUE = 5.2;
|
||||
|
||||
async function seedMarker() {
|
||||
return getPrismaClient().biomarker.create({
|
||||
data: { userId: USER_ID, name: "Kalium", unit: "mmol/L", ...CATALOG },
|
||||
});
|
||||
}
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/labs", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function readDto(analyte = "Kalium") {
|
||||
const { GET } = await import("@/app/api/labs/route");
|
||||
const res = await GET(
|
||||
new NextRequest(`http://localhost/api/labs?analyte=${analyte}`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
return body.data.results[0];
|
||||
}
|
||||
|
||||
describe("a printed reference range, end to end", () => {
|
||||
it("survives the document path onto the row and governs its verdict", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const marker = await seedMarker();
|
||||
|
||||
// The document path, through the REAL commit helper the confirm route
|
||||
// calls. The fact carries what a report prints: a value, a unit, and a
|
||||
// window stated in German notation beside it.
|
||||
const { commitApprovedFact } = await import("@/lib/documents/commit");
|
||||
const { encryptFactData, encryptFactProvenance } =
|
||||
await import("@/lib/documents/store");
|
||||
const doc = await prisma.inboundDocument.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
filename: "befund.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1024,
|
||||
contentEncrypted: Buffer.from("stand-in for the stored bytes"),
|
||||
status: "EXTRACTED",
|
||||
},
|
||||
});
|
||||
const fact = await prisma.extractedFact.create({
|
||||
data: {
|
||||
documentId: doc.id,
|
||||
userId: USER_ID,
|
||||
factType: "OBSERVATION",
|
||||
status: "APPROVED",
|
||||
confidence: 0.9,
|
||||
needsReview: false,
|
||||
dataEncrypted: encryptFactData({
|
||||
label: "Kalium",
|
||||
code: null,
|
||||
codeSystem: null,
|
||||
value: VALUE,
|
||||
valueText: null,
|
||||
unit: "mmol/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: PRINTED,
|
||||
effectiveDate: "2026-07-01",
|
||||
}),
|
||||
provenanceEncrypted: encryptFactProvenance({
|
||||
sourceText: "Kalium 5,2 mmol/l 3,9 - 5,4",
|
||||
anchored: true,
|
||||
sourceOffset: 0,
|
||||
page: 0,
|
||||
confidence: 0.9,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const ref = await commitApprovedFact(USER_ID, fact);
|
||||
expect(ref.recordType).toBe("labResult");
|
||||
|
||||
// The row carries BOTH readings of the printed window: the bounds derived
|
||||
// from it and the string itself.
|
||||
const row = await prisma.labResult.findUniqueOrThrow({
|
||||
where: { id: ref.recordId },
|
||||
});
|
||||
expect(row.sourceReferenceLow).toBe(3.9);
|
||||
expect(row.sourceReferenceHigh).toBe(5.4);
|
||||
expect(row.sourceReferenceText).toBe(PRINTED);
|
||||
// The catalog stamp is untouched — the marker already existed, so the
|
||||
// document's window did not rewrite the user's own band.
|
||||
expect(row.referenceLow).toBe(CATALOG.lowerBound);
|
||||
expect(row.referenceHigh).toBe(CATALOG.upperBound);
|
||||
const markerAfter = await prisma.biomarker.findUniqueOrThrow({
|
||||
where: { id: marker.id },
|
||||
});
|
||||
expect(markerAfter.lowerBound).toBe(CATALOG.lowerBound);
|
||||
expect(markerAfter.upperBound).toBe(CATALOG.upperBound);
|
||||
|
||||
// The read side serves both windows, names the winner, and flags that the
|
||||
// two disagree.
|
||||
const dto = await readDto();
|
||||
expect(dto).toMatchObject({
|
||||
value: VALUE,
|
||||
referenceLow: 3.9,
|
||||
referenceHigh: 5.4,
|
||||
catalogReferenceLow: CATALOG.lowerBound,
|
||||
catalogReferenceHigh: CATALOG.upperBound,
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 5.4,
|
||||
sourceReferenceText: PRINTED,
|
||||
referenceOrigin: "source",
|
||||
referenceDivergesFromCatalog: true,
|
||||
});
|
||||
|
||||
// The verdict is the point. Against the catalog band 5.2 is above range;
|
||||
// against the window the lab printed it is inside it, and the lab's window
|
||||
// is the one the physician read the value against.
|
||||
expect(dto.rangeStatus).toBe("in-range");
|
||||
|
||||
// The doctor report reads the same window, and prints the report's own
|
||||
// string rather than a reformatted version of it.
|
||||
const { loadLabResults } =
|
||||
await import("@/lib/doctor-report/clinical-records");
|
||||
const labs = await loadLabResults(
|
||||
USER_ID,
|
||||
new Date("2026-01-01T00:00:00.000Z"),
|
||||
new Date("2026-12-31T23:59:59.000Z"),
|
||||
);
|
||||
expect(labs?.[0]).toMatchObject({
|
||||
referenceLow: 3.9,
|
||||
referenceHigh: 5.4,
|
||||
catalogReferenceLow: CATALOG.lowerBound,
|
||||
catalogReferenceHigh: CATALOG.upperBound,
|
||||
sourceReferenceText: PRINTED,
|
||||
referenceOrigin: "source",
|
||||
referenceDivergesFromCatalog: true,
|
||||
});
|
||||
|
||||
// FHIR exports the same window — a receiver that trusts referenceRange
|
||||
// must not be handed the catalog band while the app shows another.
|
||||
const { labObservations } = await import("@/lib/fhir/resources/labs");
|
||||
let seq = 0;
|
||||
const observations = labObservations(
|
||||
{ labResults: labs } as Parameters<typeof labObservations>[0],
|
||||
() => `obs-${++seq}`,
|
||||
);
|
||||
expect(observations[0].referenceRange?.[0]).toMatchObject({
|
||||
low: { value: 3.9 },
|
||||
high: { value: 5.4 },
|
||||
text: PRINTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a window it cannot read as text and leaves the catalog in force", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedMarker();
|
||||
|
||||
const { POST } = await import("@/app/api/labs/route");
|
||||
const marker = await prisma.biomarker.findFirstOrThrow({
|
||||
where: { userId: USER_ID },
|
||||
});
|
||||
const res = await POST(
|
||||
postReq({
|
||||
biomarkerId: marker.id,
|
||||
value: VALUE,
|
||||
sourceReferenceText: "siehe Befund",
|
||||
takenAt: "2026-07-02T08:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = await prisma.labResult.findFirstOrThrow({
|
||||
where: { userId: USER_ID },
|
||||
});
|
||||
// The string is on the record — absence would be the old failure, and a
|
||||
// fabricated bound would be worse than either.
|
||||
expect(row.sourceReferenceText).toBe("siehe Befund");
|
||||
expect(row.sourceReferenceLow).toBeNull();
|
||||
expect(row.sourceReferenceHigh).toBeNull();
|
||||
|
||||
const dto = await readDto();
|
||||
expect(dto).toMatchObject({
|
||||
sourceReferenceText: "siehe Befund",
|
||||
referenceOrigin: "catalog",
|
||||
referenceDivergesFromCatalog: false,
|
||||
referenceLow: CATALOG.lowerBound,
|
||||
referenceHigh: CATALOG.upperBound,
|
||||
});
|
||||
// No derivable window means the catalog stays the net, so the catalog's
|
||||
// verdict stands.
|
||||
expect(dto.rangeStatus).toBe("above");
|
||||
});
|
||||
|
||||
it("carries the window through a backup round trip", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const marker = await seedMarker();
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
biomarkerId: marker.id,
|
||||
analyte: "Kalium",
|
||||
unit: "mmol/L",
|
||||
value: VALUE,
|
||||
referenceLow: CATALOG.lowerBound,
|
||||
referenceHigh: CATALOG.upperBound,
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 5.4,
|
||||
sourceReferenceText: PRINTED,
|
||||
takenAt: new Date("2026-07-01T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const { buildRecordsBackupSection } =
|
||||
await import("@/lib/export/records-backup");
|
||||
const backup = await buildRecordsBackupSection(prisma, USER_ID, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
expect(backup.labResults[0]).toMatchObject({
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 5.4,
|
||||
sourceReferenceText: PRINTED,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user