mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12: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.
524 lines
17 KiB
TypeScript
524 lines
17 KiB
TypeScript
/**
|
||
* v1.8.2 — duplicate dose-slot cleanup integration tests.
|
||
*
|
||
* Pins the one-time boot-backfill that collapses pre-fix duplicate slot
|
||
* rows: a twice-daily med ends up with a 07:00 pending REMINDER row AND
|
||
* a 07:00 taken WEB/API row for the same dose slot (the two
|
||
* `scheduledFor` instants drifting by up to a minute). The cleanup keeps
|
||
* the winner (taken > skipped > pending), soft-deletes the losers,
|
||
* normalises the winner's `scheduledFor` to the canonical slot instant,
|
||
* leaves PRN / off-slot rows untouched, and is a no-op on a second run.
|
||
*
|
||
* Coverage map:
|
||
* 1. Duplicate 07:00 pair collapses to the taken row; loser tombstoned;
|
||
* the clean 19:00 row is untouched; compliance rollup self-corrects.
|
||
* 2. A PRN med's multiple rows are never collapsed.
|
||
* 3. A second run is a no-op (idempotent).
|
||
* 4. taken > skipped tie-break keeps the taken row.
|
||
* 5. Boot discovery only matches users that actually hold duplicates.
|
||
*/
|
||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||
|
||
import {
|
||
dedupeUserIntakeSlots,
|
||
enqueueBootTimeIntakeSlotDedup,
|
||
} from "@/lib/medications/intake-slot-dedup";
|
||
import {
|
||
applyCanonicalSlotWrite,
|
||
resolveSlotInstantForWrite,
|
||
} from "@/lib/medications/scheduling/slot-upsert";
|
||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||
|
||
const TEST_USER_ID = "user-intake-slot-dedup";
|
||
const OTHER_USER_ID = "user-intake-slot-dedup-clean";
|
||
|
||
vi.mock("@/lib/db-compat", () => ({
|
||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||
}));
|
||
|
||
beforeEach(async () => {
|
||
const prisma = getPrismaClient();
|
||
await truncateAllTables(prisma);
|
||
await prisma.user.create({
|
||
data: {
|
||
id: TEST_USER_ID,
|
||
username: "intake-dedup",
|
||
email: "intake-dedup@example.test",
|
||
timezone: "Europe/Berlin",
|
||
},
|
||
});
|
||
await prisma.user.create({
|
||
data: {
|
||
id: OTHER_USER_ID,
|
||
username: "intake-dedup-clean",
|
||
email: "intake-dedup-clean@example.test",
|
||
timezone: "Europe/Berlin",
|
||
},
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Anchor every fixture on a fixed historical day so the slot instants
|
||
* are deterministic regardless of when the suite runs. 2026-03-10 is a
|
||
* non-DST-boundary weekday in Europe/Berlin.
|
||
*/
|
||
const DAY = new Date("2026-03-10T12:00:00.000Z");
|
||
|
||
async function createTwiceDailyMed(): Promise<string> {
|
||
const prisma = getPrismaClient();
|
||
const med = await prisma.medication.create({
|
||
data: {
|
||
userId: TEST_USER_ID,
|
||
name: "Bisoprolol",
|
||
dose: "2.5mg",
|
||
active: true,
|
||
schedules: {
|
||
create: {
|
||
windowStart: "07:00",
|
||
windowEnd: "07:00",
|
||
timesOfDay: ["07:00", "19:00"],
|
||
daysOfWeek: null,
|
||
scheduleType: "SCHEDULED",
|
||
},
|
||
},
|
||
},
|
||
});
|
||
return med.id;
|
||
}
|
||
|
||
describe("intake-slot-dedup — duplicate slot collapse", () => {
|
||
it("collapses the 07:00 pending+taken pair to the taken row, leaves 19:00 untouched, corrects the rollup", async () => {
|
||
const prisma = getPrismaClient();
|
||
const medId = await createTwiceDailyMed();
|
||
|
||
const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0);
|
||
const slot1900 = localHmAsUtc(DAY, "Europe/Berlin", 19, 0);
|
||
|
||
// 07:00 pending REMINDER row (server-minted, canonical instant).
|
||
const pending = await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
scheduledFor: slot0700,
|
||
takenAt: null,
|
||
skipped: false,
|
||
source: "REMINDER",
|
||
},
|
||
});
|
||
// 07:00 taken API row, drifted +1 minute (the iOS-vs-server drift).
|
||
const taken = await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
scheduledFor: new Date(slot0700.getTime() + 60_000),
|
||
takenAt: new Date(slot0700.getTime() + 60_000),
|
||
skipped: false,
|
||
source: "API",
|
||
},
|
||
});
|
||
// Clean 19:00 pending row — no duplicate.
|
||
const evening = await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
scheduledFor: slot1900,
|
||
takenAt: null,
|
||
skipped: false,
|
||
source: "REMINDER",
|
||
},
|
||
});
|
||
|
||
const summary = await dedupeUserIntakeSlots(TEST_USER_ID);
|
||
|
||
expect(summary.slotsCollapsed).toBe(1);
|
||
expect(summary.rowsSoftDeleted).toBe(1);
|
||
|
||
// Exactly one live 07:00 row survives, and it is the taken one.
|
||
const live0700 = await prisma.medicationIntakeEvent.findMany({
|
||
where: {
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
deletedAt: null,
|
||
scheduledFor: slot0700,
|
||
},
|
||
});
|
||
expect(live0700).toHaveLength(1);
|
||
expect(live0700[0]?.id).toBe(taken.id);
|
||
expect(live0700[0]?.takenAt).not.toBeNull();
|
||
// Winner normalised onto the canonical slot instant.
|
||
expect(live0700[0]?.scheduledFor.toISOString()).toBe(
|
||
slot0700.toISOString(),
|
||
);
|
||
|
||
// The loser (the pending REMINDER row) is soft-deleted, not removed.
|
||
const loser = await prisma.medicationIntakeEvent.findUniqueOrThrow({
|
||
where: { id: pending.id },
|
||
});
|
||
expect(loser.deletedAt).not.toBeNull();
|
||
|
||
// The clean 19:00 row is untouched.
|
||
const eveningRow = await prisma.medicationIntakeEvent.findUniqueOrThrow({
|
||
where: { id: evening.id },
|
||
});
|
||
expect(eveningRow.deletedAt).toBeNull();
|
||
expect(eveningRow.scheduledFor.toISOString()).toBe(slot1900.toISOString());
|
||
});
|
||
|
||
it("never collapses a PRN med's multiple rows", async () => {
|
||
const prisma = getPrismaClient();
|
||
const med = await prisma.medication.create({
|
||
data: {
|
||
userId: TEST_USER_ID,
|
||
name: "Ibuprofen PRN",
|
||
dose: "400mg",
|
||
active: true,
|
||
schedules: {
|
||
create: {
|
||
windowStart: "00:00",
|
||
windowEnd: "00:00",
|
||
timesOfDay: [],
|
||
daysOfWeek: null,
|
||
scheduleType: "PRN",
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
// Two as-needed doses logged minutes apart — both must survive.
|
||
const a = await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: TEST_USER_ID,
|
||
medicationId: med.id,
|
||
scheduledFor: new Date("2026-03-10T09:00:00.000Z"),
|
||
takenAt: new Date("2026-03-10T09:00:00.000Z"),
|
||
skipped: false,
|
||
source: "WEB",
|
||
},
|
||
});
|
||
const b = await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: TEST_USER_ID,
|
||
medicationId: med.id,
|
||
scheduledFor: new Date("2026-03-10T09:01:00.000Z"),
|
||
takenAt: new Date("2026-03-10T09:01:00.000Z"),
|
||
skipped: false,
|
||
source: "WEB",
|
||
},
|
||
});
|
||
|
||
const summary = await dedupeUserIntakeSlots(TEST_USER_ID);
|
||
expect(summary.slotsCollapsed).toBe(0);
|
||
expect(summary.rowsSoftDeleted).toBe(0);
|
||
|
||
const live = await prisma.medicationIntakeEvent.findMany({
|
||
where: { userId: TEST_USER_ID, medicationId: med.id, deletedAt: null },
|
||
});
|
||
expect(live.map((r) => r.id).sort()).toEqual([a.id, b.id].sort());
|
||
});
|
||
|
||
it("is a no-op on a second run (idempotent)", async () => {
|
||
const prisma = getPrismaClient();
|
||
const medId = await createTwiceDailyMed();
|
||
const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0);
|
||
|
||
await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
scheduledFor: slot0700,
|
||
takenAt: null,
|
||
skipped: false,
|
||
source: "REMINDER",
|
||
},
|
||
});
|
||
await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
scheduledFor: new Date(slot0700.getTime() + 60_000),
|
||
takenAt: new Date(slot0700.getTime() + 60_000),
|
||
skipped: false,
|
||
source: "API",
|
||
},
|
||
});
|
||
|
||
const first = await dedupeUserIntakeSlots(TEST_USER_ID);
|
||
expect(first.slotsCollapsed).toBe(1);
|
||
|
||
const second = await dedupeUserIntakeSlots(TEST_USER_ID);
|
||
expect(second.slotsCollapsed).toBe(0);
|
||
expect(second.rowsSoftDeleted).toBe(0);
|
||
expect(second.rowsNormalised).toBe(0);
|
||
|
||
const live = await prisma.medicationIntakeEvent.findMany({
|
||
where: { userId: TEST_USER_ID, medicationId: medId, deletedAt: null },
|
||
});
|
||
expect(live).toHaveLength(1);
|
||
expect(live[0]?.takenAt).not.toBeNull();
|
||
});
|
||
|
||
it("keeps the taken row over a skipped row in the same slot", async () => {
|
||
const prisma = getPrismaClient();
|
||
const medId = await createTwiceDailyMed();
|
||
const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0);
|
||
|
||
const skipped = await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
scheduledFor: slot0700,
|
||
takenAt: null,
|
||
skipped: true,
|
||
source: "WEB",
|
||
},
|
||
});
|
||
const taken = await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
scheduledFor: new Date(slot0700.getTime() + 30_000),
|
||
takenAt: new Date(slot0700.getTime() + 30_000),
|
||
skipped: false,
|
||
source: "API",
|
||
},
|
||
});
|
||
|
||
await dedupeUserIntakeSlots(TEST_USER_ID);
|
||
|
||
const live = await prisma.medicationIntakeEvent.findMany({
|
||
where: { userId: TEST_USER_ID, medicationId: medId, deletedAt: null },
|
||
});
|
||
expect(live).toHaveLength(1);
|
||
expect(live[0]?.id).toBe(taken.id);
|
||
|
||
const loser = await prisma.medicationIntakeEvent.findUniqueOrThrow({
|
||
where: { id: skipped.id },
|
||
});
|
||
expect(loser.deletedAt).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("intake write path — multi-dose wide window does not collapse", () => {
|
||
// The LIVE twice-daily-Ramipril regression, end-to-end through the
|
||
// DB-backed slot resolver + the canonical-slot upsert: two distinct
|
||
// same-day doses on an 08:00 / 20:00 schedule with a WIDE 08:00–22:00
|
||
// window must land as TWO separate live rows, not collapse onto one.
|
||
async function createWideWindowTwiceDailyMed(): Promise<string> {
|
||
const prisma = getPrismaClient();
|
||
const med = await prisma.medication.create({
|
||
data: {
|
||
userId: TEST_USER_ID,
|
||
name: "Ramipril",
|
||
dose: "5mg",
|
||
active: true,
|
||
schedules: {
|
||
create: {
|
||
windowStart: "08:00",
|
||
windowEnd: "22:00", // wide window — the pre-fix overlap trigger
|
||
timesOfDay: ["08:00", "20:00"],
|
||
daysOfWeek: null,
|
||
scheduleType: "SCHEDULED",
|
||
},
|
||
},
|
||
},
|
||
});
|
||
return med.id;
|
||
}
|
||
|
||
it("resolves morning and evening writes to two distinct canonical slots and lands two rows", async () => {
|
||
const prisma = getPrismaClient();
|
||
const medId = await createWideWindowTwiceDailyMed();
|
||
|
||
const morningWrite = new Date("2026-03-10T07:05:00.000Z"); // 08:05 CET
|
||
const eveningWrite = new Date("2026-03-10T19:03:00.000Z"); // 20:03 CET
|
||
|
||
const morningSlot = await resolveSlotInstantForWrite({
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
userTz: "Europe/Berlin",
|
||
incoming: morningWrite,
|
||
client: prisma,
|
||
});
|
||
const eveningSlot = await resolveSlotInstantForWrite({
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
userTz: "Europe/Berlin",
|
||
incoming: eveningWrite,
|
||
client: prisma,
|
||
});
|
||
|
||
expect(morningSlot).not.toBeNull();
|
||
expect(eveningSlot).not.toBeNull();
|
||
// The two doses must resolve to two distinct canonical slot instants.
|
||
expect(morningSlot?.toISOString()).not.toBe(eveningSlot?.toISOString());
|
||
expect(morningSlot?.toISOString()).toBe(
|
||
localHmAsUtc(DAY, "Europe/Berlin", 8, 0).toISOString(),
|
||
);
|
||
expect(eveningSlot?.toISOString()).toBe(
|
||
localHmAsUtc(DAY, "Europe/Berlin", 20, 0).toISOString(),
|
||
);
|
||
|
||
// Apply the morning taken write.
|
||
const morningResult = await applyCanonicalSlotWrite({
|
||
client: prisma,
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
canonicalSlot: morningSlot!,
|
||
takenAt: morningWrite,
|
||
skipped: false,
|
||
isExplicitTaken: true,
|
||
isExplicitSkip: false,
|
||
idempotencyKey: null,
|
||
createSource: "WEB",
|
||
});
|
||
expect(morningResult.outcome).toBe("inserted");
|
||
|
||
// Apply the evening taken write — must NOT collapse onto the morning
|
||
// slot (the bug) and must land as its own row.
|
||
const eveningResult = await applyCanonicalSlotWrite({
|
||
client: prisma,
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
canonicalSlot: eveningSlot!,
|
||
takenAt: eveningWrite,
|
||
skipped: false,
|
||
isExplicitTaken: true,
|
||
isExplicitSkip: false,
|
||
idempotencyKey: null,
|
||
createSource: "WEB",
|
||
});
|
||
expect(eveningResult.outcome).toBe("inserted");
|
||
expect(eveningResult.noDowngradeNoOp).toBe(false);
|
||
|
||
// Two distinct live rows, one per dose slot.
|
||
const live = await prisma.medicationIntakeEvent.findMany({
|
||
where: { userId: TEST_USER_ID, medicationId: medId, deletedAt: null },
|
||
orderBy: { scheduledFor: "asc" },
|
||
});
|
||
expect(live).toHaveLength(2);
|
||
expect(live[0]?.takenAt).not.toBeNull();
|
||
expect(live[1]?.takenAt).not.toBeNull();
|
||
expect(live[0]?.scheduledFor.toISOString()).toBe(
|
||
localHmAsUtc(DAY, "Europe/Berlin", 8, 0).toISOString(),
|
||
);
|
||
expect(live[1]?.scheduledFor.toISOString()).toBe(
|
||
localHmAsUtc(DAY, "Europe/Berlin", 20, 0).toISOString(),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("intake write path — defaulted-now midday does not back-fill the morning slot", () => {
|
||
// The phantom morning-dose regression, end-to-end through the DB-backed
|
||
// resolver: a bulk/sync entry that carries ONLY `takenAt` (no client
|
||
// `scheduledFor`) around midday must NOT snap onto the 07:00 slot of a
|
||
// 07:00 / 19:00 med and stamp it taken. With `instantIsExplicit:false`
|
||
// the resolver returns null (PRN) so the morning slot stays untouched.
|
||
it("a takenAt-only 12:30 write resolves to null and leaves the 07:00 slot untaken", async () => {
|
||
const prisma = getPrismaClient();
|
||
const medId = await createTwiceDailyMed();
|
||
|
||
const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0);
|
||
|
||
// Server-minted pending 07:00 reminder row — the row the phantom take
|
||
// would have stamped.
|
||
const pending = await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
scheduledFor: slot0700,
|
||
takenAt: null,
|
||
skipped: false,
|
||
source: "REMINDER",
|
||
},
|
||
});
|
||
|
||
// Bulk/sync entry: no `scheduledFor`, only `takenAt` at 12:30 local
|
||
// (10:30Z in CET). This mirrors `intake/bulk` defaulting the instant
|
||
// to `takenAt` with `instantIsExplicit: entry.scheduledFor !== undefined`.
|
||
const middayTakenAt = new Date("2026-03-10T11:30:00.000Z"); // 12:30 CET
|
||
const canonicalSlot = await resolveSlotInstantForWrite({
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
userTz: "Europe/Berlin",
|
||
incoming: middayTakenAt,
|
||
instantIsExplicit: false,
|
||
client: prisma,
|
||
});
|
||
|
||
// The defaulted-now write does NOT snap onto the far 07:00 slot.
|
||
expect(canonicalSlot).toBeNull();
|
||
|
||
// The 07:00 reminder row stays untaken — no phantom morning dose.
|
||
const morning = await prisma.medicationIntakeEvent.findUniqueOrThrow({
|
||
where: { id: pending.id },
|
||
});
|
||
expect(morning.takenAt).toBeNull();
|
||
expect(morning.skipped).toBe(false);
|
||
expect(morning.deletedAt).toBeNull();
|
||
});
|
||
|
||
it("an EXPLICIT scheduledFor:07:00 write still snaps onto the 07:00 slot", async () => {
|
||
const prisma = getPrismaClient();
|
||
const medId = await createTwiceDailyMed();
|
||
const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0);
|
||
|
||
const explicitWrite = new Date("2026-03-10T06:00:00.000Z"); // 07:00 CET
|
||
const canonicalSlot = await resolveSlotInstantForWrite({
|
||
userId: TEST_USER_ID,
|
||
medicationId: medId,
|
||
userTz: "Europe/Berlin",
|
||
incoming: explicitWrite,
|
||
instantIsExplicit: true,
|
||
client: prisma,
|
||
});
|
||
expect(canonicalSlot?.toISOString()).toBe(slot0700.toISOString());
|
||
});
|
||
});
|
||
|
||
describe("intake-slot-dedup — boot discovery scoping", () => {
|
||
it("returns 0 when no user holds a duplicate pair", async () => {
|
||
const prisma = getPrismaClient();
|
||
const med = await prisma.medication.create({
|
||
data: {
|
||
userId: OTHER_USER_ID,
|
||
name: "Clean med",
|
||
dose: "1mg",
|
||
active: true,
|
||
schedules: {
|
||
create: {
|
||
windowStart: "08:00",
|
||
windowEnd: "08:00",
|
||
timesOfDay: ["08:00"],
|
||
scheduleType: "SCHEDULED",
|
||
},
|
||
},
|
||
},
|
||
});
|
||
// A single clean row, no duplicate.
|
||
await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: OTHER_USER_ID,
|
||
medicationId: med.id,
|
||
scheduledFor: localHmAsUtc(DAY, "Europe/Berlin", 8, 0),
|
||
takenAt: null,
|
||
skipped: false,
|
||
source: "REMINDER",
|
||
},
|
||
});
|
||
|
||
// boss is not initialised in this unit context → enqueue helper
|
||
// returns the no-boss zero result. We assert the discovery query path
|
||
// by running the per-user pass directly below; the helper's
|
||
// discovery SQL is exercised in the live worker boot.
|
||
const result = await enqueueBootTimeIntakeSlotDedup();
|
||
expect(result.error).toBeNull();
|
||
expect(result.enqueued).toBe(0);
|
||
|
||
// Per-user pass on the clean user is a no-op.
|
||
const summary = await dedupeUserIntakeSlots(OTHER_USER_ID);
|
||
expect(summary.slotsCollapsed).toBe(0);
|
||
});
|
||
});
|