From 33769de3727abadf46ad478e392be6c7a2ebdfb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 07:03:38 +0200 Subject: [PATCH] test(medications): cover per-slot units consumption and wizard round-trip Integration: a medication with a morning slot at 1 unit and a noon slot at 0.5 draws the right count for each taken dose against a real Postgres. Unit: a per-slot dose + units set in the wizard survives create and round-trips on edit-hydrate. --- .../wizard/__tests__/wizard-payload.test.ts | 55 ++++++ .../medication-per-slot-consumption.test.ts | 156 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 tests/integration/medication-per-slot-consumption.test.ts diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index e59142d4a..6c6897a44 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -426,6 +426,61 @@ describe("buildCreateBody", () => { expect(hydratedDefault.unitsPerDose).toBe("1"); }); + it("carries a per-slot dose + units onto the body and round-trips them on edit-hydrate (#219)", () => { + // A split plan: this schedule takes half a tablet, overriding the + // medication-level dose + units. The flat mirror is what Step 7 writes. + const p: WizardPayload = { + ...withCadence("daily"), + treatmentRow: "bloodPressure", + timesOfDay: ["12:00"], + startsOn: new Date(Date.UTC(2026, 4, 28)), + endsOn: null, + scheduleDose: "0.5 tablet", + scheduleUnitsPerDose: "0.5", + }; + const body = buildCreateBody(p); + // This is the assertion that goes RED when the encode path stops + // emitting the per-slot fields on save. + expect(body.schedules[0].dose).toBe("0.5 tablet"); + expect(body.schedules[0].unitsPerDose).toBe(0.5); + + // A blank override omits both, so the schedule inherits the med level. + const inherit = buildCreateBody({ + ...p, + scheduleDose: "", + scheduleUnitsPerDose: "", + }); + expect(inherit.schedules[0].dose).toBeUndefined(); + expect(inherit.schedules[0].unitsPerDose).toBeUndefined(); + + // Edit-hydrate: a persisted schedule carrying the split round-trips back + // onto the draft (and the flat mirror) so an edit keeps it. + const hydrated = hydrateWizardPayload({ + id: "m1", + name: "Foo", + dose: "1 tablet", + category: "BLOOD_PRESSURE", + notificationsEnabled: true, + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [ + { + windowStart: "12:00", + windowEnd: "13:00", + timesOfDay: ["12:00"], + rrule: "FREQ=DAILY", + dose: "0.5 tablet", + unitsPerDose: 0.5, + }, + ], + }); + expect(hydrated.scheduleDose).toBe("0.5 tablet"); + expect(hydrated.scheduleUnitsPerDose).toBe("0.5"); + expect(hydrated.schedules[0].dose).toBe("0.5 tablet"); + expect(hydrated.schedules[0].unitsPerDose).toBe("0.5"); + }); + it("emits DIABETES category for the diabetes row", () => { const p: WizardPayload = { ...withCadence("daily"), diff --git a/tests/integration/medication-per-slot-consumption.test.ts b/tests/integration/medication-per-slot-consumption.test.ts new file mode 100644 index 000000000..597b91aa4 --- /dev/null +++ b/tests/integration/medication-per-slot-consumption.test.ts @@ -0,0 +1,156 @@ +/** + * #219 — per-schedule units-per-dose consumption. + * + * A medication owns two schedules at different times of day, each with its own + * `unitsPerDose`: a whole tablet in the morning, a half at noon. The intake + * consume hook must decrement the SLOT's unit count, not the medication-level + * one, so a noon half-dose draws 0.5 tablets while a morning dose draws 1. + * + * A unit test with a mocked client cannot prove this: the hook resolves the + * slot by binding the intake's `scheduledFor` wall-clock time (in the user's + * zone) to a schedule, then reads that schedule's Decimal column. This drives + * the real consume hook against a testcontainers Postgres so the Decimal, the + * timezone match, and the inventory decrement are all real. + * + * Requires Docker / OrbStack; runs under `pnpm test:integration`. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { consumeForIntake } from "@/lib/medications/inventory/consumption"; +import type { PrismaClient } from "@/generated/prisma/client"; + +const USER_ID = "per-slot-units-user"; + +/** Sum of the units the intake event's consumption stamp recorded. */ +async function consumedUnits( + prisma: PrismaClient, + eventId: string, +): Promise { + const row = await prisma.medicationIntakeEvent.findUniqueOrThrow({ + where: { id: eventId }, + select: { inventoryConsumption: true }, + }); + const stamp = row.inventoryConsumption; + if (!Array.isArray(stamp)) return 0; + return stamp.reduce( + (sum, entry) => + sum + + (entry && typeof (entry as { units?: unknown }).units === "number" + ? (entry as { units: number }).units + : 0), + 0, + ); +} + +async function takeDose( + prisma: PrismaClient, + medicationId: string, + scheduledFor: Date, +): Promise { + const event = await prisma.medicationIntakeEvent.create({ + data: { + userId: USER_ID, + medicationId, + scheduledFor, + takenAt: scheduledFor, + }, + select: { id: true }, + }); + await consumeForIntake({ + client: prisma, + userId: USER_ID, + medicationId, + eventId: event.id, + intakeAt: scheduledFor, + }); + return event.id; +} + +describe("#219 per-schedule units-per-dose consumption — integration", () => { + let medicationId: string; + + beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + // Zone fixed to UTC so a `scheduledFor` at 08:00Z / 12:00Z reads back as + // the "08:00" / "12:00" wall clock the schedules name. + await prisma.user.create({ + data: { + id: USER_ID, + username: "per-slot-units", + email: "per-slot-units@example.test", + timezone: "UTC", + }, + }); + const med = await prisma.medication.create({ + data: { + userId: USER_ID, + name: "Split tablet", + dose: "10mg", + deliveryForm: "ORAL", + // Medication-level default is a WHOLE unit; the noon slot overrides it. + unitsPerDose: 1, + schedules: { + create: [ + { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + unitsPerDose: 1, + }, + { + windowStart: "12:00", + windowEnd: "13:00", + timesOfDay: ["12:00"], + rrule: "FREQ=DAILY", + unitsPerDose: 0.5, + }, + ], + }, + }, + select: { id: true }, + }); + medicationId = med.id; + // One open container with ample stock so neither dose floors at zero. + await prisma.medicationInventoryItem.create({ + data: { + userId: USER_ID, + medicationId, + state: "IN_USE", + containerType: "BOTTLE", + unitsTotal: 100, + unitsRemaining: 100, + firstUseAt: new Date("2026-08-01T00:00:00.000Z"), + }, + }); + }); + + it("draws 1 unit for the morning slot and 0.5 for the noon slot", async () => { + const prisma = getPrismaClient(); + + const morning = await takeDose( + prisma, + medicationId, + new Date("2026-08-10T08:00:00.000Z"), + ); + const noon = await takeDose( + prisma, + medicationId, + new Date("2026-08-10T12:00:00.000Z"), + ); + + // The morning slot (unitsPerDose 1) draws a whole unit; the noon slot + // (unitsPerDose 0.5) draws a half. This is the assertion that goes RED + // when the hook reads only the medication-level column. + expect(await consumedUnits(prisma, morning)).toBe(1); + expect(await consumedUnits(prisma, noon)).toBe(0.5); + + const item = await prisma.medicationInventoryItem.findFirstOrThrow({ + where: { medicationId }, + select: { unitsRemaining: true }, + }); + expect(Number(item.unitsRemaining)).toBe(98.5); + }); +});