mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(medications): restore the containers the in-use clock wrongly wrote off
Migration 0276 reverses the rows that reached EXPIRED only through the post-opening clock on a container that never had one. It moves a row back to IN_USE when an opening date is recorded and ACTIVE when none is, and sets `expires_at` from the printed expiry — which for these rows is usually nothing at all, because they recorded none. The predicate is exact rather than approximate: nothing but the printed expiry and the in-use clock can write EXPIRED, so a clock-less container whose printed expiry is absent or still ahead can only have got there through the clock. Pens and ampoules keep their EXPIRED, so does anything whose printed date genuinely lapsed, and so does a drained container. No date is invented and no printed expiry is fabricated. The integration test seeds the before-state, applies the migration SQL from disk, and asserts both what moves and what stays — the testcontainer starts empty, so the migration running at boot proves nothing on its own.
This commit is contained in:
1 parent
a1dbd9c831
commit
bb1e01e456
2 files changed
+305
No files matched your search
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Integration coverage for `0276_inventory_expiry_container_type_repair`.
|
||||
*
|
||||
* The repair reverses the rows the in-use clock wrote off although their
|
||||
* container never carried one. A unit test cannot prove it: the predicate is
|
||||
* SQL, it reads an enum column and compares a timestamp against `NOW()`, and
|
||||
* the testcontainer starts empty so the migration itself has nothing to touch
|
||||
* when it runs at boot. This test seeds the before-state, applies the EXACT
|
||||
* migration SQL from disk, and asserts what moved, what did not, and that a
|
||||
* second application changes nothing further.
|
||||
*
|
||||
* Requires Docker / OrbStack; driven by CI's integration job (and
|
||||
* `pnpm test:integration` locally).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import type {
|
||||
MedicationContainerType,
|
||||
MedicationInventoryState,
|
||||
PrismaClient,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
const REPAIR_SQL = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const USER_ID = "inventory-repair-user";
|
||||
|
||||
interface SeedInput {
|
||||
containerType: MedicationContainerType;
|
||||
state: MedicationInventoryState;
|
||||
unitsRemaining?: number;
|
||||
firstUseAt?: Date | null;
|
||||
printedExpiry?: Date | null;
|
||||
expiresAt?: Date | null;
|
||||
}
|
||||
|
||||
async function seedItem(
|
||||
prisma: PrismaClient,
|
||||
medicationId: string,
|
||||
input: SeedInput,
|
||||
): Promise<string> {
|
||||
const row = await prisma.medicationInventoryItem.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
medicationId,
|
||||
state: input.state,
|
||||
containerType: input.containerType,
|
||||
unitsTotal: 60,
|
||||
unitsRemaining: input.unitsRemaining ?? 54,
|
||||
firstUseAt: input.firstUseAt ?? null,
|
||||
printedExpiry: input.printedExpiry ?? null,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
async function readItem(prisma: PrismaClient, id: string) {
|
||||
const row = await prisma.medicationInventoryItem.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: {
|
||||
state: true,
|
||||
expiresAt: true,
|
||||
printedExpiry: true,
|
||||
firstUseAt: true,
|
||||
unitsRemaining: true,
|
||||
},
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
describe("0276 inventory expiry container-type repair — integration", () => {
|
||||
let medicationId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "inventory-repair",
|
||||
email: "inventory-repair@example.test",
|
||||
},
|
||||
});
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
name: "Tablets",
|
||||
dose: "10mg",
|
||||
deliveryForm: "ORAL",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
medicationId = med.id;
|
||||
});
|
||||
|
||||
it("restores a wrongly expired blister to IN_USE and clears its deadline", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const openedAt = new Date(Date.now() - 45 * DAY_MS);
|
||||
const id = await seedItem(prisma, medicationId, {
|
||||
containerType: "BLISTER",
|
||||
state: "EXPIRED",
|
||||
firstUseAt: openedAt,
|
||||
printedExpiry: null,
|
||||
// What the old `computeExpiresAt` persisted: firstUseAt + 30 days.
|
||||
expiresAt: new Date(openedAt.getTime() + 30 * DAY_MS),
|
||||
});
|
||||
|
||||
await prisma.$executeRawUnsafe(REPAIR_SQL);
|
||||
|
||||
const row = await readItem(prisma, id);
|
||||
expect(row.state).toBe("IN_USE");
|
||||
// No printed expiry was recorded, so none is invented.
|
||||
expect(row.expiresAt).toBeNull();
|
||||
expect(row.printedExpiry).toBeNull();
|
||||
expect(row.firstUseAt).toEqual(openedAt);
|
||||
expect(Number(row.unitsRemaining)).toBe(54);
|
||||
});
|
||||
|
||||
it("restores an unopened clock-less container to ACTIVE", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const id = await seedItem(prisma, medicationId, {
|
||||
containerType: "OTHER",
|
||||
state: "EXPIRED",
|
||||
firstUseAt: null,
|
||||
printedExpiry: null,
|
||||
});
|
||||
|
||||
await prisma.$executeRawUnsafe(REPAIR_SQL);
|
||||
|
||||
expect((await readItem(prisma, id)).state).toBe("ACTIVE");
|
||||
});
|
||||
|
||||
it("keeps the printed expiry as the row's deadline when one is recorded", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const printed = new Date(Date.now() + 200 * DAY_MS);
|
||||
const openedAt = new Date(Date.now() - 45 * DAY_MS);
|
||||
const id = await seedItem(prisma, medicationId, {
|
||||
containerType: "BOTTLE",
|
||||
state: "EXPIRED",
|
||||
firstUseAt: openedAt,
|
||||
printedExpiry: printed,
|
||||
expiresAt: new Date(openedAt.getTime() + 30 * DAY_MS),
|
||||
});
|
||||
|
||||
await prisma.$executeRawUnsafe(REPAIR_SQL);
|
||||
|
||||
const row = await readItem(prisma, id);
|
||||
expect(row.state).toBe("IN_USE");
|
||||
expect(row.expiresAt).toEqual(printed);
|
||||
});
|
||||
|
||||
it("leaves an expired pen alone — its clock is real", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const openedAt = new Date(Date.now() - 45 * DAY_MS);
|
||||
const expiresAt = new Date(openedAt.getTime() + 30 * DAY_MS);
|
||||
const id = await seedItem(prisma, medicationId, {
|
||||
containerType: "PEN",
|
||||
state: "EXPIRED",
|
||||
firstUseAt: openedAt,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
await prisma.$executeRawUnsafe(REPAIR_SQL);
|
||||
|
||||
const row = await readItem(prisma, id);
|
||||
expect(row.state).toBe("EXPIRED");
|
||||
expect(row.expiresAt).toEqual(expiresAt);
|
||||
});
|
||||
|
||||
it("leaves an expired ampoule alone — its clock is real", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const id = await seedItem(prisma, medicationId, {
|
||||
containerType: "AMPOULE",
|
||||
state: "EXPIRED",
|
||||
firstUseAt: new Date(Date.now() - 45 * DAY_MS),
|
||||
});
|
||||
|
||||
await prisma.$executeRawUnsafe(REPAIR_SQL);
|
||||
|
||||
expect((await readItem(prisma, id)).state).toBe("EXPIRED");
|
||||
});
|
||||
|
||||
it("leaves a container whose printed expiry genuinely lapsed alone", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const printed = new Date(Date.now() - 3 * DAY_MS);
|
||||
const id = await seedItem(prisma, medicationId, {
|
||||
containerType: "BLISTER",
|
||||
state: "EXPIRED",
|
||||
firstUseAt: new Date(Date.now() - 45 * DAY_MS),
|
||||
printedExpiry: printed,
|
||||
expiresAt: printed,
|
||||
});
|
||||
|
||||
await prisma.$executeRawUnsafe(REPAIR_SQL);
|
||||
|
||||
const row = await readItem(prisma, id);
|
||||
expect(row.state).toBe("EXPIRED");
|
||||
expect(row.expiresAt).toEqual(printed);
|
||||
});
|
||||
|
||||
it("leaves a drained container alone — nothing to give back", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const id = await seedItem(prisma, medicationId, {
|
||||
containerType: "BLISTER",
|
||||
state: "EXPIRED",
|
||||
unitsRemaining: 0,
|
||||
firstUseAt: new Date(Date.now() - 45 * DAY_MS),
|
||||
});
|
||||
|
||||
await prisma.$executeRawUnsafe(REPAIR_SQL);
|
||||
|
||||
expect((await readItem(prisma, id)).state).toBe("EXPIRED");
|
||||
});
|
||||
|
||||
it.each(["ACTIVE", "IN_USE", "USED_UP"] as const)(
|
||||
"leaves a %s row untouched",
|
||||
async (state) => {
|
||||
const prisma = getPrismaClient();
|
||||
const expiresAt = new Date(Date.now() + 10 * DAY_MS);
|
||||
const id = await seedItem(prisma, medicationId, {
|
||||
containerType: "BLISTER",
|
||||
state,
|
||||
unitsRemaining: state === "USED_UP" ? 0 : 54,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
await prisma.$executeRawUnsafe(REPAIR_SQL);
|
||||
|
||||
const row = await readItem(prisma, id);
|
||||
expect(row.state).toBe(state);
|
||||
expect(row.expiresAt).toEqual(expiresAt);
|
||||
},
|
||||
);
|
||||
|
||||
it("is idempotent — a second application changes nothing", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const id = await seedItem(prisma, medicationId, {
|
||||
containerType: "BLISTER",
|
||||
state: "EXPIRED",
|
||||
firstUseAt: new Date(Date.now() - 45 * DAY_MS),
|
||||
});
|
||||
|
||||
await prisma.$executeRawUnsafe(REPAIR_SQL);
|
||||
const first = await readItem(prisma, id);
|
||||
await prisma.$executeRawUnsafe(REPAIR_SQL);
|
||||
const second = await readItem(prisma, id);
|
||||
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user