mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
/**
|
|
* The lab and medication CSV imports against a real Postgres (#1215).
|
|
*
|
|
* - a dry run writes nothing (no reading, no marker, no medication) and
|
|
* reports what the real run then does;
|
|
* - names resolve to existing markers, new analytes mint one marker each,
|
|
* a reading in another unit is converted and keeps what the file said;
|
|
* - re-uploading the same file writes nothing: every row is a duplicate;
|
|
* - a refused row is reported by line and does not stop the rest;
|
|
* - medications take a daily schedule, reminders off, as-needed without
|
|
* schedule, and a dated window opens a course;
|
|
* - a medication and its category land together or not at all.
|
|
*/
|
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { textToStream } from "@/lib/import/csv-stream";
|
|
import { importLabsCsv } from "@/lib/import/labs-csv";
|
|
import { importMedicationsCsv } from "@/lib/import/medications-csv";
|
|
import {
|
|
labsCsvQuerySchema,
|
|
medicationsCsvQuerySchema,
|
|
} from "@/lib/validations/record-import";
|
|
|
|
vi.mock("@/lib/jobs/boss-instance", () => ({
|
|
getGlobalBoss: vi.fn(() => null),
|
|
}));
|
|
const categoryFault = vi.hoisted(() => ({ on: false }));
|
|
vi.mock("@/lib/medication-category", async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import("@/lib/medication-category")>();
|
|
return {
|
|
...actual,
|
|
setMedicationCategory: vi.fn(
|
|
async (...args: Parameters<typeof actual.setMedicationCategory>) => {
|
|
if (categoryFault.on) throw new Error("category write failed");
|
|
return actual.setMedicationCategory(...args);
|
|
},
|
|
),
|
|
};
|
|
});
|
|
|
|
const prisma = getPrismaClient();
|
|
const USER = "csv-import-user";
|
|
const TZ = "Europe/Berlin";
|
|
|
|
const LABS = [
|
|
"Analyt;Wert;Einheit;Datum;Referenz",
|
|
"Glucose;5,5;mmol/L;01.03.2025;3,9 - 5,5",
|
|
"Glukose;99;mg/dL;02.03.2025;",
|
|
"HbA1c;5,4;%;01.03.2025;4,0-5,6",
|
|
"Urinstatus Eiweiß;negativ;;01.03.2025;",
|
|
"Ferritin;1.234;ng/mL;01.03.2025;",
|
|
"Ferritin;85;;01.03.2025;",
|
|
"Neuer Marker;12;U/l;03.03.2025;",
|
|
].join("\n");
|
|
|
|
function labsQuery(dryRun: boolean) {
|
|
return labsCsvQuerySchema.parse({
|
|
analyte: "0",
|
|
value: "1",
|
|
unit: "2",
|
|
date: "3",
|
|
referenceRange: "4",
|
|
decimal: "comma",
|
|
dateOrder: "dmy",
|
|
dryRun: dryRun ? "1" : "0",
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
await prisma.user.create({
|
|
data: { id: USER, username: USER, timezone: TZ },
|
|
});
|
|
await prisma.biomarker.create({
|
|
// No analyte key stored: the import derives it the way the write does.
|
|
data: { userId: USER, name: "Glucose", unit: "mg/dL" },
|
|
});
|
|
await prisma.biomarkerAlias.create({
|
|
data: {
|
|
userId: USER,
|
|
biomarkerId: (
|
|
await prisma.biomarker.findFirstOrThrow({ where: { userId: USER } })
|
|
).id,
|
|
name: "Glukose",
|
|
nameFolded: "glukose",
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
});
|
|
|
|
// One walk: a dry run, the import, the same file again. Each step reads what
|
|
// the one before it wrote, so the order holds under a shuffled run.
|
|
describe("lab CSV import", { shuffle: false }, () => {
|
|
it("a dry run writes nothing and reports the plan", async () => {
|
|
const result = await importLabsCsv({
|
|
userId: USER,
|
|
tz: TZ,
|
|
stream: textToStream(LABS, 16),
|
|
maxBytes: 1_000_000,
|
|
query: labsQuery(true),
|
|
});
|
|
expect(result).toMatchObject({
|
|
rows: 7,
|
|
valid: 5,
|
|
inserted: 5,
|
|
failed: 2,
|
|
duplicates: 0,
|
|
});
|
|
expect(result.errors).toEqual([
|
|
{ line: 6, reason: "ambiguous_number", field: "value" },
|
|
{ line: 7, reason: "missing_unit", field: "unit" },
|
|
]);
|
|
expect(result.markers?.created.sort()).toEqual([
|
|
"HbA1c",
|
|
"Neuer Marker",
|
|
"Urinstatus Eiweiß",
|
|
]);
|
|
expect(result.markers?.matched.map((m) => m.how).sort()).toEqual([
|
|
"alias",
|
|
"name",
|
|
]);
|
|
expect(await prisma.labResult.count({ where: { userId: USER } })).toBe(0);
|
|
expect(await prisma.biomarker.count({ where: { userId: USER } })).toBe(1);
|
|
});
|
|
|
|
it("the import converts, mints and keeps what the file said", async () => {
|
|
const result = await importLabsCsv({
|
|
userId: USER,
|
|
tz: TZ,
|
|
stream: textToStream(LABS, 16),
|
|
maxBytes: 1_000_000,
|
|
query: labsQuery(false),
|
|
});
|
|
expect(result).toMatchObject({ inserted: 5, failed: 2 });
|
|
const glucose = await prisma.labResult.findMany({
|
|
where: { userId: USER, analyte: "Glucose" },
|
|
orderBy: { takenAt: "asc" },
|
|
});
|
|
expect(glucose).toHaveLength(2);
|
|
expect(glucose[0].unit).toBe("mg/dL");
|
|
expect(glucose[0].value).toBeCloseTo(99.1, 0);
|
|
expect(glucose[0].sourceValue).toBe(5.5);
|
|
expect(glucose[0].sourceUnit).toBe("mmol/L");
|
|
expect(glucose[0].source).toBe("IMPORT");
|
|
expect(glucose[1].value).toBe(99);
|
|
const text = await prisma.labResult.findFirstOrThrow({
|
|
where: { userId: USER, valueText: "negativ" },
|
|
});
|
|
expect(text.value).toBeNull();
|
|
expect(await prisma.biomarker.count({ where: { userId: USER } })).toBe(4);
|
|
});
|
|
|
|
it("re-uploading the same file writes nothing", async () => {
|
|
const before = await prisma.labResult.count({ where: { userId: USER } });
|
|
const result = await importLabsCsv({
|
|
userId: USER,
|
|
tz: TZ,
|
|
stream: textToStream(LABS, 7),
|
|
maxBytes: 1_000_000,
|
|
query: labsQuery(false),
|
|
});
|
|
expect(result).toMatchObject({ inserted: 0, duplicates: 5, failed: 2 });
|
|
expect(await prisma.labResult.count({ where: { userId: USER } })).toBe(
|
|
before,
|
|
);
|
|
});
|
|
|
|
it("refuses a unit that does not convert into the marker's", async () => {
|
|
const result = await importLabsCsv({
|
|
userId: USER,
|
|
tz: TZ,
|
|
stream: textToStream(
|
|
"Analyt;Wert;Einheit;Datum\nGlucose;7;mmHg;05.03.2025\n",
|
|
),
|
|
maxBytes: 1_000_000,
|
|
query: labsCsvQuerySchema.parse({
|
|
analyte: "0",
|
|
value: "1",
|
|
unit: "2",
|
|
date: "3",
|
|
decimal: "comma",
|
|
dateOrder: "dmy",
|
|
}),
|
|
});
|
|
expect(result.errors).toEqual([
|
|
{ line: 2, reason: "unit_mismatch", field: "unit" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("lab CSV import in a zone of the file's own", () => {
|
|
// An account of its own, so its two draws are not in the other cases' counts.
|
|
const ZONE_USER = "csv-import-zone-user";
|
|
beforeAll(async () => {
|
|
await prisma.user.create({
|
|
data: { id: ZONE_USER, username: ZONE_USER, timezone: TZ },
|
|
});
|
|
});
|
|
|
|
it("cuts the duplicate day in the zone the file is read in", async () => {
|
|
// Read in New York, these are draws on two days (1 and 2 March). In the
|
|
// account's Berlin they both fall on 2 March, which used to make the
|
|
// second a duplicate of the first.
|
|
const csv = [
|
|
"Analyt;Wert;Einheit;Datum",
|
|
"Zone marker;7;mg/dL;2025-03-01T22:30",
|
|
"Zone marker;7;mg/dL;2025-03-02T02:00",
|
|
].join("\n");
|
|
const result = await importLabsCsv({
|
|
userId: ZONE_USER,
|
|
tz: TZ,
|
|
stream: textToStream(csv),
|
|
maxBytes: 1_000_000,
|
|
query: labsCsvQuerySchema.parse({
|
|
analyte: "0",
|
|
value: "1",
|
|
unit: "2",
|
|
date: "3",
|
|
timezone: "America/New_York",
|
|
}),
|
|
});
|
|
expect(result).toMatchObject({ inserted: 2, duplicates: 0 });
|
|
});
|
|
});
|
|
|
|
describe("lab CSV import at the row cap", () => {
|
|
const BIG_USER = "csv-import-big-user";
|
|
// 20 000 rows: 40 markers, 500 draw days each, one reading per day.
|
|
const lines = ["Analyt;Wert;Einheit;Datum"];
|
|
for (let i = 0; i < 20_000; i++) {
|
|
const marker = `Marker ${i % 40}`;
|
|
const day = new Date(Date.UTC(2020, 0, 1) + Math.floor(i / 40) * 86_400_000)
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
lines.push(`${marker};${(i % 97) + 1};U/l;${day}`);
|
|
}
|
|
const BIG = lines.join("\n");
|
|
const query = () =>
|
|
labsCsvQuerySchema.parse({
|
|
analyte: "0",
|
|
value: "1",
|
|
unit: "2",
|
|
date: "3",
|
|
});
|
|
|
|
/** The file, failing like a dropped connection after `bytes`. */
|
|
function cutAt(bytes: number): ReadableStream<Uint8Array> {
|
|
const all = new TextEncoder().encode(BIG);
|
|
let offset = 0;
|
|
return new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
if (offset >= bytes) {
|
|
controller.error(new Error("connection reset"));
|
|
return;
|
|
}
|
|
controller.enqueue(all.slice(offset, Math.min(offset + 64_000, bytes)));
|
|
offset += 64_000;
|
|
},
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await prisma.user.create({
|
|
data: { id: BIG_USER, username: BIG_USER, timezone: TZ },
|
|
});
|
|
});
|
|
|
|
it("an import cut off part way leaves whole chunks, and a retry writes the rest once", async () => {
|
|
let written = 0;
|
|
await expect(
|
|
importLabsCsv({
|
|
userId: BIG_USER,
|
|
tz: TZ,
|
|
stream: cutAt(Math.floor(BIG.length / 3)),
|
|
maxBytes: 16 * 1024 * 1024,
|
|
query: query(),
|
|
onWritten: (n) => {
|
|
written += n;
|
|
},
|
|
}),
|
|
).rejects.toThrow("connection reset");
|
|
const afterCut = await prisma.labResult.count({
|
|
where: { userId: BIG_USER },
|
|
});
|
|
expect(afterCut).toBe(written);
|
|
expect(afterCut).toBeGreaterThan(0);
|
|
// Whole chunks only: nothing half-written.
|
|
expect(afterCut % 200).toBe(0);
|
|
|
|
const started = Date.now();
|
|
const result = await importLabsCsv({
|
|
userId: BIG_USER,
|
|
tz: TZ,
|
|
stream: textToStream(BIG),
|
|
maxBytes: 16 * 1024 * 1024,
|
|
query: query(),
|
|
});
|
|
const elapsed = Date.now() - started;
|
|
expect(result).toMatchObject({
|
|
rows: 20_000,
|
|
inserted: 20_000 - afterCut,
|
|
duplicates: afterCut,
|
|
failed: 0,
|
|
});
|
|
expect(await prisma.labResult.count({ where: { userId: BIG_USER } })).toBe(
|
|
20_000,
|
|
);
|
|
expect(await prisma.biomarker.count({ where: { userId: BIG_USER } })).toBe(
|
|
40,
|
|
);
|
|
// Generous: the chunked write takes a few seconds here; the bound only
|
|
// catches a return to one statement per row.
|
|
expect(elapsed).toBeLessThan(120_000);
|
|
}, 300_000);
|
|
});
|
|
|
|
describe("medication CSV import", () => {
|
|
const MEDS = [
|
|
"name,dose,times,as needed,start,end",
|
|
"Ramipril,5 mg,08:00,,2025-01-01,",
|
|
"Metformin,500 mg,08:00 20:00,,,",
|
|
"Ibuprofen,400 mg,,yes,,",
|
|
"Vitamin D,1000 IE,,,,",
|
|
"ramipril,5 MG,08:00,,,",
|
|
].join("\n");
|
|
const query = (dryRun: boolean) =>
|
|
medicationsCsvQuerySchema.parse({
|
|
name: "0",
|
|
dose: "1",
|
|
times: "2",
|
|
asNeeded: "3",
|
|
startsOn: "4",
|
|
endsOn: "5",
|
|
dryRun: dryRun ? "1" : "0",
|
|
});
|
|
|
|
it("checks, then imports, then skips everything on a second upload", async () => {
|
|
const dry = await importMedicationsCsv({
|
|
userId: USER,
|
|
tz: TZ,
|
|
stream: textToStream(MEDS),
|
|
maxBytes: 100_000,
|
|
query: query(true),
|
|
});
|
|
expect(dry).toMatchObject({
|
|
rows: 5,
|
|
inserted: 3,
|
|
duplicates: 1,
|
|
failed: 1,
|
|
});
|
|
expect(dry.errors).toEqual([
|
|
{ line: 5, reason: "missing_times", field: "times" },
|
|
{ line: 6, reason: "duplicate" },
|
|
]);
|
|
expect(await prisma.medication.count({ where: { userId: USER } })).toBe(0);
|
|
|
|
const real = await importMedicationsCsv({
|
|
userId: USER,
|
|
tz: TZ,
|
|
stream: textToStream(MEDS),
|
|
maxBytes: 100_000,
|
|
query: query(false),
|
|
});
|
|
expect(real).toMatchObject({ inserted: 3, duplicates: 1, failed: 1 });
|
|
const meds = await prisma.medication.findMany({
|
|
where: { userId: USER },
|
|
include: { schedules: true, courses: true },
|
|
orderBy: { name: "asc" },
|
|
});
|
|
const byName = new Map(meds.map((m) => [m.name, m]));
|
|
expect(byName.get("Metformin")?.schedules[0]).toMatchObject({
|
|
rrule: "FREQ=DAILY",
|
|
timesOfDay: ["08:00", "20:00"],
|
|
});
|
|
expect(byName.get("Metformin")?.notificationsEnabled).toBe(false);
|
|
expect(byName.get("Ibuprofen")).toMatchObject({ asNeeded: true });
|
|
expect(byName.get("Ibuprofen")?.schedules).toHaveLength(0);
|
|
expect(byName.get("Ramipril")?.courses).toHaveLength(1);
|
|
|
|
const again = await importMedicationsCsv({
|
|
userId: USER,
|
|
tz: TZ,
|
|
stream: textToStream(MEDS),
|
|
maxBytes: 100_000,
|
|
query: query(false),
|
|
});
|
|
expect(again).toMatchObject({ inserted: 0, duplicates: 4 });
|
|
});
|
|
});
|
|
|
|
describe("medication CSV import, a crash between medication and category", () => {
|
|
// An account of its own: the medication import case counts every
|
|
// medication its account holds.
|
|
const CRASH_USER = "csv-import-crash-user";
|
|
beforeAll(async () => {
|
|
await prisma.user.create({
|
|
data: { id: CRASH_USER, username: CRASH_USER, timezone: TZ },
|
|
});
|
|
});
|
|
|
|
const CSV = ["name,dose,times", "Atorvastatin,20 mg,20:00"].join("\n");
|
|
const query = medicationsCsvQuerySchema.parse({
|
|
name: "0",
|
|
dose: "1",
|
|
times: "2",
|
|
dryRun: "0",
|
|
});
|
|
|
|
it("leaves no medication without a category, so the retry writes it whole", async () => {
|
|
categoryFault.on = true;
|
|
await expect(
|
|
importMedicationsCsv({
|
|
userId: CRASH_USER,
|
|
tz: TZ,
|
|
stream: textToStream(CSV),
|
|
maxBytes: 100_000,
|
|
query,
|
|
}),
|
|
).rejects.toThrow("category write failed");
|
|
categoryFault.on = false;
|
|
expect(
|
|
await prisma.medication.count({
|
|
where: { userId: CRASH_USER, name: "Atorvastatin" },
|
|
}),
|
|
).toBe(0);
|
|
|
|
const retry = await importMedicationsCsv({
|
|
userId: CRASH_USER,
|
|
tz: TZ,
|
|
stream: textToStream(CSV),
|
|
maxBytes: 100_000,
|
|
query,
|
|
});
|
|
expect(retry).toMatchObject({ inserted: 1, duplicates: 0 });
|
|
const med = await prisma.medication.findFirstOrThrow({
|
|
where: { userId: CRASH_USER, name: "Atorvastatin" },
|
|
select: { id: true },
|
|
});
|
|
expect(
|
|
await prisma.medicationCategoryAssignment.count({
|
|
where: { medicationId: med.id },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
});
|