mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
A lab report usually prints the collection date once, in its header. The extraction puts that date on the document (reportDate) and leaves each value's own date empty, and the confirm step filed every such value under the moment it was confirmed, so an old report showed up as today's results. The confirm route now reads the document's report date and filing date with the document it already loads, and an undated value takes the report date, else the filing date, else now: the order the day view places a document by. A stated date, a lab value's or a condition's onset, now counts only when it names a real calendar day. 2021-02-30 used to be stored as 2 March, and 2021-13-01 became an invalid date that failed the whole confirm.
403 lines
12 KiB
TypeScript
403 lines
12 KiB
TypeScript
/**
|
|
* v1.25.1 — commit-path unit-integrity tests.
|
|
*
|
|
* Committing a numeric observation against an EXISTING biomarker must not
|
|
* silently rewrite the document's stated unit with the catalog's. When the
|
|
* units disagree the fact is rejected (fail closed) so the user reconciles on
|
|
* the review screen, exactly like the `unitRequired` guard — never a corrupt
|
|
* "6.1 mmol/L written as 6.1 mg/dL" reading.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
labResult: { create: vi.fn() },
|
|
illnessEpisode: { create: vi.fn() },
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/labs/biomarker-store", () => ({
|
|
resolveOrMintBiomarker: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/documents/store", () => ({
|
|
decryptFactData: vi.fn(),
|
|
}));
|
|
|
|
import { commitApprovedFact, FactCommitError } from "@/lib/documents/commit";
|
|
import { prisma } from "@/lib/db";
|
|
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
|
import { decryptFactData } from "@/lib/documents/store";
|
|
import type {
|
|
ExtractedFact,
|
|
IllnessEpisode,
|
|
LabResult,
|
|
} from "@/generated/prisma/client";
|
|
import { userDayKey } from "@/lib/tz/format";
|
|
import type {
|
|
ConditionFactData,
|
|
ObservationFactData,
|
|
} from "@/lib/validations/inbound-documents";
|
|
|
|
function observationFact(): ExtractedFact {
|
|
return {
|
|
id: "fact-1",
|
|
factType: "OBSERVATION",
|
|
dataEncrypted: new Uint8Array(),
|
|
} as unknown as ExtractedFact;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
describe("commitApprovedFact — OBSERVATION unit integrity", () => {
|
|
it("rejects a numeric observation whose stated unit differs from an existing marker's unit", async () => {
|
|
// A pair the registry has no conversion for: ferritin in pmol/L against a
|
|
// marker stored in ng/mL (protein molecular weights vary by source).
|
|
vi.mocked(decryptFactData).mockReturnValue({
|
|
label: "Ferritin",
|
|
value: 450,
|
|
unit: "pmol/L",
|
|
referenceLow: null,
|
|
referenceHigh: null,
|
|
effectiveDate: "2025-01-01",
|
|
} as never);
|
|
// Existing marker stored in ng/mL — the resolver returns it as-is.
|
|
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
|
id: "bm-1",
|
|
name: "Ferritin",
|
|
unit: "ng/mL",
|
|
lowerBound: 30,
|
|
upperBound: 400,
|
|
panel: null,
|
|
analyteKey: null,
|
|
});
|
|
|
|
await expect(
|
|
commitApprovedFact("user-1", observationFact()),
|
|
).rejects.toMatchObject({ code: "observation.unitMismatch" });
|
|
await expect(
|
|
commitApprovedFact("user-1", observationFact()),
|
|
).rejects.toBeInstanceOf(FactCommitError);
|
|
expect(prisma.labResult.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("converts a unit the marker's analyte can reach, keeping what the document stated", async () => {
|
|
vi.mocked(decryptFactData).mockReturnValue({
|
|
label: "Glucose",
|
|
value: 6.1,
|
|
unit: "mmol/L",
|
|
referenceLow: 3.9,
|
|
referenceHigh: 5.6,
|
|
effectiveDate: "2025-01-01",
|
|
} as never);
|
|
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
|
id: "bm-1",
|
|
name: "Glucose",
|
|
unit: "mg/dL",
|
|
lowerBound: 70,
|
|
upperBound: 100,
|
|
panel: null,
|
|
analyteKey: "fasting-glucose",
|
|
});
|
|
vi.mocked(prisma.labResult.create).mockResolvedValue({
|
|
id: "lr-conv",
|
|
} as never);
|
|
|
|
const ref = await commitApprovedFact("user-1", observationFact());
|
|
expect(ref).toEqual({ recordType: "labResult", recordId: "lr-conv" });
|
|
const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data;
|
|
expect(data.value).toBeCloseTo(6.1 * 18.0182, 9);
|
|
expect(data.unit).toBe("mg/dL");
|
|
expect(data.sourceValue).toBe(6.1);
|
|
expect(data.sourceUnit).toBe("mmol/L");
|
|
// The report's own window moved with the value.
|
|
expect(data.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 9);
|
|
expect(data.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9);
|
|
});
|
|
|
|
it("stores a reading in the marker's own unit without a conversion record", async () => {
|
|
vi.mocked(decryptFactData).mockReturnValue({
|
|
label: "Glucose",
|
|
value: 95,
|
|
unit: "mg/dL",
|
|
referenceLow: null,
|
|
referenceHigh: null,
|
|
effectiveDate: "2025-01-01",
|
|
} as never);
|
|
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
|
id: "bm-1",
|
|
name: "Glucose",
|
|
unit: "mg/dL",
|
|
lowerBound: null,
|
|
upperBound: null,
|
|
panel: null,
|
|
analyteKey: "fasting-glucose",
|
|
});
|
|
vi.mocked(prisma.labResult.create).mockResolvedValue({
|
|
id: "lr-same",
|
|
} as never);
|
|
|
|
await commitApprovedFact("user-1", observationFact());
|
|
const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data;
|
|
expect(data.value).toBe(95);
|
|
expect(data.sourceValue).toBeNull();
|
|
expect(data.sourceUnit).toBeNull();
|
|
});
|
|
|
|
it("commits when the stated unit is the marker's unit spelled another way", async () => {
|
|
vi.mocked(decryptFactData).mockReturnValue({
|
|
label: "Glucose",
|
|
value: 95,
|
|
unit: " mg/DL ",
|
|
referenceLow: null,
|
|
referenceHigh: null,
|
|
effectiveDate: "2025-01-01",
|
|
} as never);
|
|
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
|
id: "bm-1",
|
|
name: "Glucose",
|
|
unit: "mg/dL",
|
|
lowerBound: 70,
|
|
upperBound: 100,
|
|
panel: null,
|
|
analyteKey: null,
|
|
});
|
|
vi.mocked(prisma.labResult.create).mockResolvedValue({
|
|
id: "lr-1",
|
|
} as never);
|
|
|
|
const ref = await commitApprovedFact("user-1", observationFact());
|
|
expect(ref).toEqual({ recordType: "labResult", recordId: "lr-1" });
|
|
expect(prisma.labResult.create).toHaveBeenCalledOnce();
|
|
// The stated date is stored at noon UTC: it reads back on the stated day
|
|
// as a UTC label and formatted in a zone west of UTC alike. UTC midnight
|
|
// showed the reading a day early there.
|
|
const takenAt = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data
|
|
.takenAt as Date;
|
|
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,
|
|
analyteKey: 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" });
|
|
});
|
|
});
|
|
|
|
describe("commitApprovedFact — OBSERVATION date when the value states none", () => {
|
|
// A lab report that prints the collection date once in its header carries
|
|
// no date beside each value. Mutation check: drop the document day from
|
|
// `takenAt` → the report-date, filing-date and impossible-date cases file
|
|
// the reading under today.
|
|
function undated(effectiveDate: string | null = null) {
|
|
const data: ObservationFactData = {
|
|
label: "Ferritin",
|
|
code: null,
|
|
codeSystem: null,
|
|
value: 80,
|
|
valueText: null,
|
|
unit: "ng/mL",
|
|
referenceLow: null,
|
|
referenceHigh: null,
|
|
effectiveDate,
|
|
};
|
|
vi.mocked(decryptFactData).mockReturnValue(data);
|
|
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
|
id: "bm-3",
|
|
name: "Ferritin",
|
|
unit: "ng/mL",
|
|
lowerBound: null,
|
|
upperBound: null,
|
|
panel: null,
|
|
analyteKey: null,
|
|
});
|
|
vi.mocked(prisma.labResult.create).mockResolvedValue({
|
|
id: "lr-3",
|
|
} as LabResult);
|
|
}
|
|
|
|
/** The dates as the confirm route reads them off the stored document. */
|
|
function dates(reportDate: string | null, documentDate: string | null) {
|
|
return {
|
|
// An older report date sits at UTC midnight, newer ones at noon UTC.
|
|
reportDate: reportDate ? new Date(`${reportDate}T00:00:00.000Z`) : null,
|
|
documentDate: documentDate
|
|
? new Date(`${documentDate}T12:00:00.000Z`)
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function storedTakenAt(): Date {
|
|
return vi.mocked(prisma.labResult.create).mock.calls[0]![0].data
|
|
.takenAt as Date;
|
|
}
|
|
|
|
it("keeps the value's own date", async () => {
|
|
undated("2021-03-04");
|
|
|
|
await commitApprovedFact(
|
|
"user-1",
|
|
observationFact(),
|
|
{},
|
|
dates("2021-03-01", "2026-10-20"),
|
|
);
|
|
|
|
expect(storedTakenAt().toISOString()).toBe("2021-03-04T12:00:00.000Z");
|
|
});
|
|
|
|
it("takes the report date the document states over its filing date", async () => {
|
|
undated();
|
|
|
|
await commitApprovedFact(
|
|
"user-1",
|
|
observationFact(),
|
|
{},
|
|
dates("2021-03-01", "2026-10-20"),
|
|
);
|
|
|
|
expect(storedTakenAt().toISOString()).toBe("2021-03-01T12:00:00.000Z");
|
|
});
|
|
|
|
it("takes the filing date when the document states no report date", async () => {
|
|
undated();
|
|
|
|
await commitApprovedFact(
|
|
"user-1",
|
|
observationFact(),
|
|
{},
|
|
dates(null, "2021-02-15"),
|
|
);
|
|
|
|
expect(storedTakenAt().toISOString()).toBe("2021-02-15T12:00:00.000Z");
|
|
});
|
|
|
|
it.each([
|
|
["not a date", "March 2021"],
|
|
["a day the month does not have", "2021-02-30"],
|
|
["a month that does not exist", "2021-13-01"],
|
|
])(
|
|
"reads a value whose own date is %s as undated",
|
|
async (_label, effectiveDate) => {
|
|
undated(effectiveDate);
|
|
|
|
await commitApprovedFact(
|
|
"user-1",
|
|
observationFact(),
|
|
{},
|
|
dates("2021-03-01", null),
|
|
);
|
|
|
|
// Not 2 March for 30 February, and no Invalid Date for month 13.
|
|
expect(storedTakenAt().toISOString()).toBe("2021-03-01T12:00:00.000Z");
|
|
},
|
|
);
|
|
|
|
it("files the reading under now when neither the value nor the document has a date", async () => {
|
|
undated();
|
|
|
|
const before = Date.now();
|
|
await commitApprovedFact(
|
|
"user-1",
|
|
observationFact(),
|
|
{},
|
|
dates(null, null),
|
|
);
|
|
const after = Date.now();
|
|
|
|
const takenAt = storedTakenAt().getTime();
|
|
expect(takenAt).toBeGreaterThanOrEqual(before);
|
|
expect(takenAt).toBeLessThanOrEqual(after);
|
|
});
|
|
});
|
|
|
|
describe("commitApprovedFact — CONDITION onset", () => {
|
|
// The onset goes through the same stated-day check as a lab value's date.
|
|
function condition(onsetDate: string | null) {
|
|
const data: ConditionFactData = {
|
|
label: "Iron deficiency",
|
|
code: null,
|
|
codeSystem: null,
|
|
clinicalStatus: null,
|
|
verificationStatus: null,
|
|
onsetDate,
|
|
};
|
|
vi.mocked(decryptFactData).mockReturnValue(data);
|
|
vi.mocked(prisma.illnessEpisode.create).mockResolvedValue({
|
|
id: "ep-1",
|
|
} as IllnessEpisode);
|
|
}
|
|
|
|
function conditionFact(): ExtractedFact {
|
|
return {
|
|
id: "fact-2",
|
|
factType: "CONDITION",
|
|
dataEncrypted: new Uint8Array(),
|
|
} as unknown as ExtractedFact;
|
|
}
|
|
|
|
function storedOnset(): Date {
|
|
return vi.mocked(prisma.illnessEpisode.create).mock.calls[0]![0].data
|
|
.onsetAt as Date;
|
|
}
|
|
|
|
it("keeps a stated onset day", async () => {
|
|
condition("2021-03-04");
|
|
|
|
await commitApprovedFact("user-1", conditionFact());
|
|
|
|
expect(storedOnset().toISOString()).toBe("2021-03-04T12:00:00.000Z");
|
|
});
|
|
|
|
it("files an onset that is not a real day under now, not an invalid date", async () => {
|
|
// 2021-13-01 used to become an invalid date that failed the whole confirm.
|
|
condition("2021-13-01");
|
|
|
|
const before = Date.now();
|
|
await commitApprovedFact("user-1", conditionFact());
|
|
const after = Date.now();
|
|
|
|
const onset = storedOnset().getTime();
|
|
expect(onset).toBeGreaterThanOrEqual(before);
|
|
expect(onset).toBeLessThanOrEqual(after);
|
|
});
|
|
});
|