mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Persist record and actor identities through job completion.\nWrite one bounded transactional completion audit per job.
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
/**
|
|
* The reported export, from file text to rows in Postgres.
|
|
*
|
|
* A self-hoster attached a 3,395-row dose history covering sixteen medications
|
|
* across three and a half years, two UTC offsets, and three statuses. They had
|
|
* been hand-mapping it into the two-field shape the intake importer accepted,
|
|
* which is where the scheduled time and the time taken collapse into one.
|
|
*
|
|
* A mocked Prisma cannot show whether that survived. The claim is about rows: how
|
|
* many exist afterwards, what instants they carry, and that the pair the file
|
|
* kept apart is still two columns. So the real file goes through the real
|
|
* derivation into a real Postgres and the assertions are counts and instants read
|
|
* back out.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { toJson } from "@/lib/db";
|
|
import {
|
|
_setMedicationIntakeImportPrismaForTests,
|
|
processMedicationIntakeImportJob,
|
|
} from "@/lib/jobs/medication-intake-import";
|
|
import { parseAutoExportCsv } from "@/lib/medications/import/auto-export-parse";
|
|
import { planAutoExportImport } from "@/lib/medications/import/auto-export-plan";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("@/lib/cache/invalidate", () => ({
|
|
invalidateUserMedications: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
|
|
queueMedicationIntakeSync: vi.fn(),
|
|
}));
|
|
|
|
/** The export as it arrived, de-identified, byte for byte. */
|
|
const FIXTURE = readFileSync(
|
|
join(
|
|
__dirname,
|
|
"..",
|
|
"..",
|
|
"src/lib/medications/import/__tests__/fixtures/dose-history-export.csv",
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
/** After the newest row, so the plausibility bound is fixed rather than today's. */
|
|
const NOW = Date.parse("2026-07-01T00:00:00.000Z");
|
|
|
|
const MEDICATION_NAMES = Array.from(
|
|
{ length: 16 },
|
|
(_, index) => `Med_${index + 1}`,
|
|
);
|
|
|
|
beforeEach(async () => {
|
|
const prisma = getPrismaClient();
|
|
await truncateAllTables(prisma);
|
|
_setMedicationIntakeImportPrismaForTests(prisma);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterAll(() => {
|
|
_setMedicationIntakeImportPrismaForTests(null);
|
|
});
|
|
|
|
async function seedRegimen(names: readonly string[] = MEDICATION_NAMES) {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "dose-history",
|
|
email: "dose-history@example.test",
|
|
role: "USER",
|
|
// Deliberately not the export's own zone: the offsets in the file decide
|
|
// the instants, and the user's zone decides only which local day each dose
|
|
// rolls up into. Sharing one zone would hide a mistake in either.
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
});
|
|
for (const name of names) {
|
|
await prisma.medication.create({
|
|
data: { userId: user.id, name, dose: "1 tablet", unitsPerDose: 1 },
|
|
});
|
|
}
|
|
return user;
|
|
}
|
|
|
|
/** The production derivation, not a restatement of it. */
|
|
async function enqueue(userId: string) {
|
|
const prisma = getPrismaClient();
|
|
const medications = await prisma.medication.findMany({
|
|
where: { userId },
|
|
select: { id: true, name: true, externalSource: true },
|
|
});
|
|
const plan = planAutoExportImport(
|
|
parseAutoExportCsv(FIXTURE, { now: NOW }),
|
|
medications,
|
|
);
|
|
const job = await prisma.medicationIntakeImportJob.create({
|
|
data: {
|
|
recordUserId: userId,
|
|
actorUserId: userId,
|
|
// Account-wide: the file covers sixteen medications, so no single one owns
|
|
// the job and each entry names its own.
|
|
medicationId: null,
|
|
payload: toJson({ entries: plan.entries }),
|
|
progress: toJson({
|
|
processed: 0,
|
|
total: plan.entries.length,
|
|
imported: 0,
|
|
skippedByReason: plan.skippedByReason,
|
|
skipDetails: plan.skipDetails,
|
|
skippedDetailsOmitted: plan.skippedDetailsOmitted,
|
|
touchedDays: [],
|
|
rollupProcessed: 0,
|
|
}),
|
|
},
|
|
});
|
|
return { jobId: job.id, plan };
|
|
}
|
|
|
|
describe("dose-history import — the reported export end to end", () => {
|
|
it("lands every dose the file records a decision for", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedRegimen();
|
|
const { jobId, plan } = await enqueue(user.id);
|
|
|
|
const result = await processMedicationIntakeImportJob(jobId);
|
|
|
|
expect(result).toEqual({
|
|
imported: 3387,
|
|
skipped: 8,
|
|
// The eight `Not Interacted` rows: the file states no decision about those
|
|
// doses, so there is nothing honest to write, and the count says which.
|
|
skipReasons: [{ reason: "status_no_dose_information", count: 8 }],
|
|
skipDetails: plan.skipDetails,
|
|
skippedDetailsOmitted: 0,
|
|
});
|
|
// Every row of the file is accounted for by one of the two numbers.
|
|
expect(result!.imported + result!.skipped).toBe(3395);
|
|
|
|
await expect(
|
|
prisma.medicationIntakeEvent.count({ where: { userId: user.id } }),
|
|
).resolves.toBe(3387);
|
|
const sources = await prisma.medicationIntakeEvent.findMany({
|
|
where: { userId: user.id },
|
|
distinct: ["source"],
|
|
select: { source: true },
|
|
});
|
|
expect(sources.map((row) => row.source)).toEqual(["IMPORT"]);
|
|
}, 240_000);
|
|
|
|
it("keeps the scheduled slot and the take as two facts", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedRegimen();
|
|
const { jobId } = await enqueue(user.id);
|
|
await processMedicationIntakeImportJob(jobId);
|
|
|
|
const med13 = await prisma.medication.findFirstOrThrow({
|
|
where: { userId: user.id, name: "Med_13" },
|
|
});
|
|
// Row 2 of the file: `2023-02-16 08:38:00 +1030` taken against a
|
|
// `2023-02-16 08:00:00 +1030` slot. Both offsets survive to the second.
|
|
const first = await prisma.medicationIntakeEvent.findFirstOrThrow({
|
|
where: { userId: user.id, medicationId: med13.id },
|
|
orderBy: { scheduledFor: "asc" },
|
|
});
|
|
expect(first.scheduledFor.toISOString()).toBe("2023-02-15T21:30:00.000Z");
|
|
expect(first.takenAt?.toISOString()).toBe("2023-02-15T22:08:00.000Z");
|
|
expect(first.skipped).toBe(false);
|
|
|
|
const late = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
|
SELECT COUNT(*)::bigint AS count
|
|
FROM "medication_intake_events"
|
|
WHERE "user_id" = ${user.id}
|
|
AND "taken_at" > "scheduled_for"
|
|
`;
|
|
// 2,799 doses landed after they were due. Anchoring each on its own take
|
|
// time — all the old two-field payload could express — would have written
|
|
// 3,366 doses that every compliance read then calls perfectly on time.
|
|
expect(Number(late[0].count)).toBe(2799);
|
|
}, 240_000);
|
|
|
|
it("writes a skip as a dose not taken, not as a take", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedRegimen();
|
|
const { jobId } = await enqueue(user.id);
|
|
await processMedicationIntakeImportJob(jobId);
|
|
|
|
const skips = await prisma.medicationIntakeEvent.findMany({
|
|
where: { userId: user.id, skipped: true },
|
|
select: { takenAt: true, doseTaken: true },
|
|
});
|
|
expect(skips).toHaveLength(21);
|
|
for (const skip of skips) {
|
|
expect(skip.takenAt).toBeNull();
|
|
expect(skip.doseTaken).toBeNull();
|
|
}
|
|
}, 240_000);
|
|
|
|
it("records a per-dose amount only where the file says the dose deviated", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedRegimen();
|
|
const { jobId } = await enqueue(user.id);
|
|
await processMedicationIntakeImportJob(jobId);
|
|
|
|
const overridden = await prisma.medicationIntakeEvent.findMany({
|
|
where: { userId: user.id, doseTaken: { not: null } },
|
|
select: { doseTaken: true },
|
|
});
|
|
expect(overridden).toHaveLength(25);
|
|
expect(new Set(overridden.map((row) => row.doseTaken))).toEqual(
|
|
new Set(["0.25", "0.5", "1", "1.25", "2"]),
|
|
);
|
|
}, 240_000);
|
|
|
|
it("re-importing the same file adds nothing and says why", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedRegimen();
|
|
const first = await enqueue(user.id);
|
|
await processMedicationIntakeImportJob(first.jobId);
|
|
|
|
const second = await enqueue(user.id);
|
|
const result = await processMedicationIntakeImportJob(second.jobId);
|
|
|
|
expect(result).toMatchObject({
|
|
imported: 0,
|
|
skipped: 3395,
|
|
skipReasons: [
|
|
{ reason: "already_recorded", count: 3387 },
|
|
{ reason: "status_no_dose_information", count: 8 },
|
|
],
|
|
});
|
|
expect(result?.skipDetails).toHaveLength(200);
|
|
expect(result?.skipDetails?.slice(0, 8)).toEqual(second.plan.skipDetails);
|
|
expect(result?.skippedDetailsOmitted).toBe(3195);
|
|
const detailWire = JSON.stringify(result?.skipDetails);
|
|
expect(detailWire).not.toContain("Med_");
|
|
expect(detailWire).not.toContain("scheduledFor");
|
|
expect(detailWire).not.toContain("takenAt");
|
|
await expect(
|
|
prisma.medicationIntakeEvent.count({ where: { userId: user.id } }),
|
|
).resolves.toBe(3387);
|
|
}, 480_000);
|
|
|
|
it("refuses the rows of a medication that is not on the record, by name", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedRegimen(["Med_13", "Med_9"]);
|
|
const { jobId, plan } = await enqueue(user.id);
|
|
|
|
const result = await processMedicationIntakeImportJob(jobId);
|
|
|
|
expect(plan.unmatchedMedications).toHaveLength(14);
|
|
expect(result!.imported).toBe(plan.entries.length);
|
|
expect(result!.imported + result!.skipped).toBe(3395);
|
|
expect(
|
|
result!.skipReasons.find(
|
|
(group) => group.reason === "medication_not_found",
|
|
)?.count,
|
|
).toBe(3387 - plan.entries.length);
|
|
// Nothing was created to hold the refused rows.
|
|
await expect(
|
|
prisma.medication.count({ where: { userId: user.id } }),
|
|
).resolves.toBe(2);
|
|
}, 240_000);
|
|
|
|
it("imports an inactive medication's history without putting it back on the active list", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedRegimen();
|
|
// Standing in for a medication archived in the source app: the person has
|
|
// stopped taking it here too. Importing the history it names is one act;
|
|
// making the medication current again is a different one, and only the first
|
|
// was asked for. Someone re-adding it later should find the history waiting,
|
|
// not find it already re-added on their behalf.
|
|
await prisma.medication.updateMany({
|
|
where: { userId: user.id },
|
|
data: { active: false },
|
|
});
|
|
|
|
const { jobId } = await enqueue(user.id);
|
|
const result = await processMedicationIntakeImportJob(jobId);
|
|
|
|
expect(result!.imported).toBe(3387);
|
|
const stillInactive = await prisma.medication.count({
|
|
where: { userId: user.id, active: false },
|
|
});
|
|
expect(stillInactive).toBe(16);
|
|
}, 240_000);
|
|
|
|
it("re-folds the compliance rollup for every medication and day it touched", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedRegimen();
|
|
const { jobId } = await enqueue(user.id);
|
|
await processMedicationIntakeImportJob(jobId);
|
|
|
|
const rollups = await prisma.medicationComplianceRollup.findMany({
|
|
where: { userId: user.id },
|
|
select: { medicationId: true, day: true },
|
|
});
|
|
// One rollup row per (medication, local day) the import wrote into. The job
|
|
// spans sixteen medications, so a bare day key could not have addressed
|
|
// them — two medications on one day are two rows.
|
|
expect(rollups.length).toBeGreaterThan(3000);
|
|
expect(new Set(rollups.map((row) => row.medicationId)).size).toBe(16);
|
|
expect(
|
|
new Set(rollups.map((row) => `${row.medicationId} ${row.day}`)).size,
|
|
).toBe(rollups.length);
|
|
}, 480_000);
|
|
});
|