mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Deleting a measurement or mood entry can be undone from the toast via new restore endpoints, dialogs submit on enter with busy states, silent mutations gain error toasts, and the settings area finishes its consolidation: provider and integration cards live in focused subcomponents, every setting persists server-side at exactly one home, and the save conventions are written down. The remaining raw fetches in settings move onto the shared client.
534 lines
18 KiB
TypeScript
534 lines
18 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());
|
||
|
||
// The compliance rollup reflects the corrected counts: the 07:00 day
|
||
// now has scheduled=2 (07:00 taken + 19:00 pending), taken=1 — not the
|
||
// inflated scheduled=3 the duplicate produced.
|
||
const rollups = await prisma.medicationComplianceRollup.findMany({
|
||
where: { userId: TEST_USER_ID, medicationId: medId },
|
||
});
|
||
expect(rollups).toHaveLength(1);
|
||
expect(rollups[0]?.scheduled).toBe(2);
|
||
expect(rollups[0]?.taken).toBe(1);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|