mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
medication_compliance_rollups lost its last reader when compliance moved to the schedule-anchored computation, yet every dose write still upserted it, a boot backfill refolded it, and the wipe and backup plans carried it. The per-dose hook keeps the one derived row that still hangs off a dose day, the supplement nutrients, under dose-day-refresh. The pending-row mints (today projection, reminder) no longer call it, since an untaken dose adds no nutrients. The import job finishes without its per-day recompute phase; its progress keeps touchedDays and rollupProcessed on the wire, inert and documented as deprecated. The backfill queue is retired on boot and migration 0414 drops the table under a bounded lock wait.
292 lines
9.1 KiB
TypeScript
292 lines
9.1 KiB
TypeScript
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import {
|
|
_setMedicationIntakeImportPrismaForTests,
|
|
processMedicationIntakeImportJob,
|
|
type MedicationImportPayload,
|
|
type MedicationImportProgress,
|
|
} from "@/lib/jobs/medication-intake-import";
|
|
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
|
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
|
import { toJson } from "@/lib/db";
|
|
|
|
vi.mock("@/lib/cache/invalidate", () => ({
|
|
invalidateUserMedications: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
|
|
queueMedicationIntakeSync: vi.fn(),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
const prisma = getPrismaClient();
|
|
await truncateAllTables(prisma);
|
|
_setMedicationIntakeImportPrismaForTests(prisma);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterAll(() => {
|
|
_setMedicationIntakeImportPrismaForTests(null);
|
|
});
|
|
|
|
describe("medication intake import job — concurrent retry", () => {
|
|
it("produces one event and one inventory consumption per unique import key", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "med-import-owner",
|
|
email: "med-import-owner@example.test",
|
|
role: "USER",
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
});
|
|
const medication = await prisma.medication.create({
|
|
data: {
|
|
userId: user.id,
|
|
name: "Test medication",
|
|
dose: "1 tablet",
|
|
unitsPerDose: 1,
|
|
},
|
|
});
|
|
const inventory = await prisma.medicationInventoryItem.create({
|
|
data: {
|
|
userId: user.id,
|
|
medicationId: medication.id,
|
|
unitsTotal: 10,
|
|
unitsRemaining: 10,
|
|
},
|
|
});
|
|
|
|
const payload: MedicationImportPayload = {
|
|
entries: [
|
|
{
|
|
scheduledFor: "2026-07-20T05:00:00.000Z",
|
|
takenAt: "2026-07-20T05:00:00.000Z",
|
|
idempotencyKey: `import-${medication.id}-1`,
|
|
},
|
|
{
|
|
scheduledFor: "2026-07-20T05:00:00.000Z",
|
|
takenAt: "2026-07-20T05:00:00.000Z",
|
|
idempotencyKey: `import-${medication.id}-1`,
|
|
},
|
|
{
|
|
scheduledFor: "2026-07-20T17:00:00.000Z",
|
|
takenAt: "2026-07-20T17:00:00.000Z",
|
|
idempotencyKey: `import-${medication.id}-2`,
|
|
},
|
|
{
|
|
scheduledFor: "2026-07-21T05:00:00.000Z",
|
|
takenAt: "2026-07-21T05:00:00.000Z",
|
|
idempotencyKey: `import-${medication.id}-3`,
|
|
},
|
|
],
|
|
};
|
|
const progress: MedicationImportProgress = {
|
|
processed: 0,
|
|
total: payload.entries.length,
|
|
imported: 0,
|
|
skippedByReason: {},
|
|
touchedDays: [],
|
|
rollupProcessed: 0,
|
|
};
|
|
const job = await prisma.medicationIntakeImportJob.create({
|
|
data: {
|
|
recordUserId: user.id,
|
|
actorUserId: user.id,
|
|
medicationId: medication.id,
|
|
payload: toJson(payload),
|
|
progress: toJson(progress),
|
|
},
|
|
});
|
|
|
|
await Promise.all([
|
|
processMedicationIntakeImportJob(job.id),
|
|
processMedicationIntakeImportJob(job.id),
|
|
]);
|
|
await processMedicationIntakeImportJob(job.id);
|
|
|
|
const [events, refreshedInventory, refreshedJob, audits] =
|
|
await Promise.all([
|
|
prisma.medicationIntakeEvent.findMany({
|
|
where: { userId: user.id, medicationId: medication.id },
|
|
orderBy: { idempotencyKey: "asc" },
|
|
}),
|
|
prisma.medicationInventoryItem.findUniqueOrThrow({
|
|
where: { id: inventory.id },
|
|
}),
|
|
prisma.medicationIntakeImportJob.findUniqueOrThrow({
|
|
where: { id: job.id },
|
|
}),
|
|
prisma.auditLog.findMany({
|
|
where: {
|
|
userId: user.id,
|
|
action: "medication.intake.import",
|
|
},
|
|
}),
|
|
]);
|
|
|
|
expect(events).toHaveLength(3);
|
|
expect(events.map((event) => event.idempotencyKey)).toEqual([
|
|
`import-${medication.id}-1`,
|
|
`import-${medication.id}-2`,
|
|
`import-${medication.id}-3`,
|
|
]);
|
|
expect(
|
|
events.every(
|
|
(event) =>
|
|
Array.isArray(event.inventoryConsumption) &&
|
|
event.inventoryConsumption.length === 1,
|
|
),
|
|
).toBe(true);
|
|
expect(Number(refreshedInventory.unitsRemaining)).toBe(7);
|
|
expect(refreshedJob.status).toBe("done");
|
|
// The two entries sharing an instant collapse inside the submission; that
|
|
// is `duplicate_in_file`, and it is reported under its own name rather
|
|
// than as an undifferentiated "duplicate".
|
|
expect(refreshedJob.result).toEqual({
|
|
imported: 3,
|
|
skipped: 1,
|
|
skipReasons: [{ reason: "duplicate_in_file", count: 1 }],
|
|
});
|
|
expect(refreshedJob.progress).toEqual({
|
|
processed: 4,
|
|
total: 4,
|
|
imported: 3,
|
|
skippedByReason: { duplicate_in_file: 1 },
|
|
// Inert since the compliance rollup was dropped: written at admission
|
|
// and never advanced.
|
|
touchedDays: [],
|
|
rollupProcessed: 0,
|
|
});
|
|
expect(audits).toHaveLength(1);
|
|
expect(invalidateUserMedications).toHaveBeenCalledTimes(1);
|
|
expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rolls final effects back with a failed terminal marker and retries once", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "med-import-finalize",
|
|
email: "med-import-finalize@example.test",
|
|
role: "USER",
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
});
|
|
const medication = await prisma.medication.create({
|
|
data: {
|
|
userId: user.id,
|
|
name: "Finalize medication",
|
|
dose: "1 tablet",
|
|
unitsPerDose: 1,
|
|
},
|
|
});
|
|
const payload: MedicationImportPayload = {
|
|
entries: [
|
|
{
|
|
scheduledFor: "2026-07-20T05:00:00.000Z",
|
|
takenAt: "2026-07-20T05:00:00.000Z",
|
|
idempotencyKey: `import-${medication.id}-final`,
|
|
},
|
|
],
|
|
};
|
|
const progress: MedicationImportProgress = {
|
|
processed: 0,
|
|
total: 1,
|
|
imported: 0,
|
|
skippedByReason: {},
|
|
touchedDays: [],
|
|
rollupProcessed: 0,
|
|
};
|
|
const job = await prisma.medicationIntakeImportJob.create({
|
|
data: {
|
|
recordUserId: user.id,
|
|
actorUserId: user.id,
|
|
medicationId: medication.id,
|
|
payload: toJson(payload),
|
|
progress: toJson(progress),
|
|
},
|
|
});
|
|
|
|
let rejectTerminalMarker = true;
|
|
const clientWithTerminalFailure = {
|
|
$transaction: (
|
|
operation: (tx: Prisma.TransactionClient) => Promise<unknown>,
|
|
) =>
|
|
prisma.$transaction(async (tx) => {
|
|
const jobDelegate = new Proxy(tx.medicationIntakeImportJob, {
|
|
get(target, property, receiver) {
|
|
if (property !== "update") {
|
|
return Reflect.get(target, property, receiver);
|
|
}
|
|
return async (
|
|
args: Prisma.MedicationIntakeImportJobUpdateArgs,
|
|
) => {
|
|
if (rejectTerminalMarker && args.data.status === "done") {
|
|
rejectTerminalMarker = false;
|
|
throw new Error("terminal marker failed");
|
|
}
|
|
return tx.medicationIntakeImportJob.update(args);
|
|
};
|
|
},
|
|
});
|
|
const transaction = new Proxy(tx, {
|
|
get(target, property, receiver) {
|
|
if (property === "medicationIntakeImportJob") {
|
|
return jobDelegate;
|
|
}
|
|
return Reflect.get(target, property, receiver);
|
|
},
|
|
});
|
|
return operation(transaction);
|
|
}),
|
|
} as unknown as PrismaClient;
|
|
|
|
_setMedicationIntakeImportPrismaForTests(clientWithTerminalFailure);
|
|
await expect(processMedicationIntakeImportJob(job.id)).rejects.toThrow(
|
|
"terminal marker failed",
|
|
);
|
|
|
|
const afterFailedFinalization = await Promise.all([
|
|
prisma.medicationIntakeEvent.count({
|
|
where: { medicationId: medication.id },
|
|
}),
|
|
prisma.auditLog.count({
|
|
where: {
|
|
userId: user.id,
|
|
action: "medication.intake.import",
|
|
},
|
|
}),
|
|
prisma.medicationIntakeImportJob.findUniqueOrThrow({
|
|
where: { id: job.id },
|
|
}),
|
|
]);
|
|
expect(afterFailedFinalization[0]).toBe(1);
|
|
expect(afterFailedFinalization[1]).toBe(0);
|
|
expect(afterFailedFinalization[2].status).toBe("running");
|
|
|
|
_setMedicationIntakeImportPrismaForTests(prisma);
|
|
await processMedicationIntakeImportJob(job.id);
|
|
await processMedicationIntakeImportJob(job.id);
|
|
|
|
const [events, audits, completedJob] = await Promise.all([
|
|
prisma.medicationIntakeEvent.findMany({
|
|
where: { medicationId: medication.id },
|
|
}),
|
|
prisma.auditLog.findMany({
|
|
where: {
|
|
userId: user.id,
|
|
action: "medication.intake.import",
|
|
},
|
|
}),
|
|
prisma.medicationIntakeImportJob.findUniqueOrThrow({
|
|
where: { id: job.id },
|
|
}),
|
|
]);
|
|
expect(events).toHaveLength(1);
|
|
expect(audits).toHaveLength(1);
|
|
expect(completedJob.status).toBe("done");
|
|
});
|
|
});
|