mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
`medication_intake_events.idempotency_key` carried a GLOBAL unique from 0001_init. The value is client-supplied, so two people running the same client can mint the same key — and on a global unique the second one is simply refused by the constraint. Their dose is not written and nothing tells them so. The bulk drain made it worse: its replay probe read the key with no user scope, resolved the first user's row, and reported the second user's entry as a duplicate carrying a stranger's row id. Migration 0273 replaces the global unique with `(user_id, idempotency_key)`, the grain every other client-supplied identity on the table already uses. Both unscoped reads in the bulk drain now key on the composite; the other four reads were already narrowed by user. On existing data this is a relaxation, so nothing needs reconciling: the old constraint is strictly stronger than the new one, and no pair of rows can violate an index the previous one already forbade. The migration still carries a reconciliation step for a database whose global index was dropped by hand at some point. It never deletes or rewrites a dose — it clears the dedup HINT on the later row of a colliding pair and keeps every intake event, timestamp, attribution and inventory stamp intact. The new index is created before the old one is dropped, so a failure aborts with the old protection still in place. A structural guard freezes the read side: no `medicationIntakeEvent` query may filter on the key without narrowing by `userId`.
770 lines
25 KiB
TypeScript
770 lines
25 KiB
TypeScript
/**
|
|
* v1.4.30 — `POST /api/medications/intake/bulk` real-Postgres
|
|
* integration.
|
|
*
|
|
* Asserts the iOS SyncMode bulk-backfill contract:
|
|
* - inserts a clean batch
|
|
* - skips entries that point at a medication the user doesn't own
|
|
* - returns `duplicate` when an idempotencyKey is re-used
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { localHmAsUtc } from "@/lib/tz/local-day";
|
|
|
|
const TEST_USER_ID = "user-medications-intake-bulk";
|
|
const OTHER_USER_ID = "user-medications-intake-other";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
let ownedMedId = "";
|
|
let foreignMedId = "";
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.createMany({
|
|
data: [
|
|
{
|
|
id: TEST_USER_ID,
|
|
username: "intake-bulk",
|
|
email: "intake-bulk@example.test",
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
{
|
|
id: OTHER_USER_ID,
|
|
username: "intake-other",
|
|
email: "intake-other@example.test",
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
],
|
|
});
|
|
const owned = await prisma.medication.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
name: "Mounjaro",
|
|
dose: "5mg",
|
|
active: true,
|
|
},
|
|
});
|
|
ownedMedId = owned.id;
|
|
const foreign = await prisma.medication.create({
|
|
data: {
|
|
userId: OTHER_USER_ID,
|
|
name: "Levothyroxin",
|
|
dose: "50µg",
|
|
active: true,
|
|
},
|
|
});
|
|
foreignMedId = foreign.id;
|
|
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
// Long expiry so the session survives the cases that pin the clock
|
|
// forward to local noon (a 60-minute expiry would read as expired
|
|
// under the faked time).
|
|
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
});
|
|
|
|
function makeRequest(body: unknown): NextRequest {
|
|
return new NextRequest("http://localhost/api/medications/intake/bulk", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
describe("POST /api/medications/intake/bulk (real Postgres)", () => {
|
|
it("inserts a clean batch", async () => {
|
|
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
|
const res = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
medicationId: ownedMedId,
|
|
scheduledFor: "2026-05-16T08:00:00.000Z",
|
|
takenAt: "2026-05-16T08:02:00.000Z",
|
|
},
|
|
{
|
|
medicationId: ownedMedId,
|
|
scheduledFor: "2026-05-17T08:00:00.000Z",
|
|
skipped: true,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as {
|
|
data: { processed: number; inserted: number; duplicates: number };
|
|
};
|
|
expect(json.data.processed).toBe(2);
|
|
expect(json.data.inserted).toBe(2);
|
|
|
|
const stored = await getPrismaClient().medicationIntakeEvent.findMany({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(stored).toHaveLength(2);
|
|
});
|
|
|
|
it("skips entries that reference a medication the user doesn't own", async () => {
|
|
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
|
const res = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
medicationId: ownedMedId,
|
|
scheduledFor: "2026-05-16T08:00:00.000Z",
|
|
},
|
|
{
|
|
medicationId: foreignMedId,
|
|
scheduledFor: "2026-05-16T08:00:00.000Z",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const json = (await res.json()) as {
|
|
data: {
|
|
inserted: number;
|
|
skipped: Array<{ index: number; reason: string }>;
|
|
};
|
|
};
|
|
expect(json.data.inserted).toBe(1);
|
|
expect(json.data.skipped).toEqual([
|
|
{ index: 1, reason: "medication_not_found" },
|
|
]);
|
|
});
|
|
|
|
// v1.8.2 — duplicate-intake slot collapse. A scheduled med carries a
|
|
// pending REMINDER row at the canonical `localHmAsUtc` slot instant;
|
|
// an iOS "Genommen" write (source API) must UPDATE that row, not insert
|
|
// a second source-API row that differs only by source + sub-minute
|
|
// drift.
|
|
describe("v1.8.2 — source-agnostic slot collapse", () => {
|
|
const TZ = "Europe/Berlin";
|
|
|
|
// A taken-write must never snap onto a FUTURE slot, so the 07:00
|
|
// taken-write cases below pin the clock to local noon (see
|
|
// `pinClockAfterMorningSlot`) so the slot sits in the past even when
|
|
// the suite runs before 07:00 local. Sibling cases that use a later
|
|
// slot or fixed dates keep real time, hence the per-test pin. This
|
|
// afterEach is a no-op for the cases that never faked.
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// Pin only Date (leaving Prisma's real timers) to today's local noon,
|
|
// computed from the real date so it stays on the current day.
|
|
function pinClockAfterMorningSlot(): void {
|
|
const noon = new Date();
|
|
noon.setHours(12, 0, 0, 0);
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
vi.setSystemTime(noon);
|
|
}
|
|
|
|
async function makeScheduledMed(timesOfDay: string[]): 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: timesOfDay[0],
|
|
windowEnd: timesOfDay[0],
|
|
timesOfDay,
|
|
daysOfWeek: null,
|
|
scheduleType: "SCHEDULED",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
return med.id;
|
|
}
|
|
|
|
it("collapses an API taken-write onto a pre-existing pending REMINDER slot row (exact instant)", async () => {
|
|
pinClockAfterMorningSlot();
|
|
const prisma = getPrismaClient();
|
|
const medId = await makeScheduledMed(["07:00"]);
|
|
// The projector/worker minted this pending REMINDER row.
|
|
const slot = localHmAsUtc(new Date(), TZ, 7, 0);
|
|
const pending = await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
medicationId: medId,
|
|
scheduledFor: slot,
|
|
takenAt: null,
|
|
skipped: false,
|
|
source: "REMINDER",
|
|
},
|
|
});
|
|
|
|
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
|
const res = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
medicationId: medId,
|
|
scheduledFor: slot.toISOString(),
|
|
takenAt: slot.toISOString(),
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as {
|
|
data: {
|
|
inserted: number;
|
|
updated: number;
|
|
entries: Array<{ status: string }>;
|
|
};
|
|
};
|
|
expect(json.data.inserted).toBe(0);
|
|
expect(json.data.updated).toBe(1);
|
|
expect(json.data.entries[0]?.status).toBe("updated");
|
|
|
|
const rows = await prisma.medicationIntakeEvent.findMany({
|
|
where: { userId: TEST_USER_ID, medicationId: medId },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.id).toBe(pending.id);
|
|
expect(rows[0]?.takenAt).not.toBeNull();
|
|
expect(rows[0]?.source).toBe("REMINDER"); // original source preserved
|
|
});
|
|
|
|
it("collapses even when the write's scheduledFor drifts by 1 minute", async () => {
|
|
pinClockAfterMorningSlot();
|
|
const prisma = getPrismaClient();
|
|
const medId = await makeScheduledMed(["07:00"]);
|
|
const slot = localHmAsUtc(new Date(), TZ, 7, 0);
|
|
await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
medicationId: medId,
|
|
scheduledFor: slot,
|
|
takenAt: null,
|
|
skipped: false,
|
|
source: "REMINDER",
|
|
},
|
|
});
|
|
|
|
const drifted = new Date(slot.getTime() + 60_000); // +1 min
|
|
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
|
const res = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
medicationId: medId,
|
|
scheduledFor: drifted.toISOString(),
|
|
takenAt: drifted.toISOString(),
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const json = (await res.json()) as {
|
|
data: { inserted: number; updated: number };
|
|
};
|
|
expect(json.data.updated).toBe(1);
|
|
expect(json.data.inserted).toBe(0);
|
|
|
|
const rows = await prisma.medicationIntakeEvent.findMany({
|
|
where: { userId: TEST_USER_ID, medicationId: medId },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.scheduledFor.toISOString()).toBe(slot.toISOString());
|
|
});
|
|
|
|
it("C2 — a pending echo onto an already-TAKEN slot does NOT clear takenAt", async () => {
|
|
// Medical-safety invariant: an iOS offline re-sync replays a PENDING
|
|
// projection (no takenAt, skipped=false) for a slot the user already
|
|
// marked TAKEN. That echo must NOT downgrade the recorded dose.
|
|
const prisma = getPrismaClient();
|
|
const medId = await makeScheduledMed(["07:00"]);
|
|
const slot = localHmAsUtc(new Date(), TZ, 7, 0);
|
|
const takenAt = new Date(slot.getTime() + 90_000); // taken 1.5 min late
|
|
const taken = await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
medicationId: medId,
|
|
scheduledFor: slot,
|
|
takenAt,
|
|
skipped: false,
|
|
source: "WEB",
|
|
},
|
|
});
|
|
|
|
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
|
const res = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
medicationId: medId,
|
|
scheduledFor: slot.toISOString(),
|
|
// no takenAt, skipped omitted → pending projection echo
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as {
|
|
data: {
|
|
inserted: number;
|
|
updated: number;
|
|
duplicates: number;
|
|
entries: Array<{ status: string }>;
|
|
};
|
|
};
|
|
// Reported as duplicate so the iOS cursor advances WITHOUT downgrading.
|
|
expect(json.data.duplicates).toBe(1);
|
|
expect(json.data.updated).toBe(0);
|
|
expect(json.data.inserted).toBe(0);
|
|
expect(json.data.entries[0]?.status).toBe("duplicate");
|
|
|
|
const rows = await prisma.medicationIntakeEvent.findMany({
|
|
where: { userId: TEST_USER_ID, medicationId: medId, deletedAt: null },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.id).toBe(taken.id);
|
|
// The recorded dose is intact — takenAt was NOT cleared.
|
|
expect(rows[0]?.takenAt?.toISOString()).toBe(takenAt.toISOString());
|
|
});
|
|
|
|
it("does NOT collapse PRN doses — two as-needed logs keep two rows", async () => {
|
|
const prisma = getPrismaClient();
|
|
const med = await prisma.medication.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
name: "Ibuprofen",
|
|
dose: "400mg",
|
|
active: true,
|
|
schedules: {
|
|
create: {
|
|
windowStart: "00:00",
|
|
windowEnd: "00:00",
|
|
timesOfDay: [],
|
|
daysOfWeek: null,
|
|
scheduleType: "PRN",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
|
// Relative past instants: takenAt now carries a no-future plausibility
|
|
// bound, so fixed calendar dates would rot into rejections.
|
|
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
const t1 = new Date(yesterday.setUTCHours(9, 0, 0, 0));
|
|
const t2 = new Date(yesterday.setUTCHours(15, 0, 0, 0));
|
|
await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
medicationId: med.id,
|
|
scheduledFor: t1.toISOString(),
|
|
takenAt: t1.toISOString(),
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const res = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
medicationId: med.id,
|
|
scheduledFor: t2.toISOString(),
|
|
takenAt: t2.toISOString(),
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const json = (await res.json()) as {
|
|
data: { inserted: number; updated: number };
|
|
};
|
|
expect(json.data.inserted).toBe(1);
|
|
expect(json.data.updated).toBe(0);
|
|
|
|
const rows = await prisma.medicationIntakeEvent.findMany({
|
|
where: { userId: TEST_USER_ID, medicationId: med.id },
|
|
});
|
|
expect(rows).toHaveLength(2);
|
|
});
|
|
|
|
it("creates exactly one row for a fresh scheduled dose with no pre-existing slot row", async () => {
|
|
const prisma = getPrismaClient();
|
|
const medId = await makeScheduledMed(["19:00"]);
|
|
// Yesterday's slot: today's 19:00 is in the future for any run before
|
|
// the evening, and a future takenAt is now rejected by design.
|
|
const slot = localHmAsUtc(
|
|
new Date(Date.now() - 24 * 60 * 60 * 1000),
|
|
TZ,
|
|
19,
|
|
0,
|
|
);
|
|
|
|
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
|
const res = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
medicationId: medId,
|
|
scheduledFor: slot.toISOString(),
|
|
takenAt: slot.toISOString(),
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const json = (await res.json()) as {
|
|
data: { inserted: number; updated: number };
|
|
};
|
|
expect(json.data.inserted).toBe(1);
|
|
expect(json.data.updated).toBe(0);
|
|
|
|
const rows = await prisma.medicationIntakeEvent.findMany({
|
|
where: { userId: TEST_USER_ID, medicationId: medId },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
// snapped to the canonical slot instant
|
|
expect(rows[0]?.scheduledFor.toISOString()).toBe(slot.toISOString());
|
|
});
|
|
});
|
|
|
|
it("returns `duplicate` when an idempotencyKey is re-used", async () => {
|
|
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
|
const body = {
|
|
entries: [
|
|
{
|
|
medicationId: ownedMedId,
|
|
scheduledFor: "2026-05-16T08:00:00.000Z",
|
|
idempotencyKey: "ios-sync-key-001",
|
|
},
|
|
],
|
|
};
|
|
await POST(makeRequest(body));
|
|
const res = await POST(makeRequest(body));
|
|
const json = (await res.json()) as {
|
|
data: { duplicates: number; inserted: number };
|
|
};
|
|
expect(json.data.duplicates).toBe(1);
|
|
expect(json.data.inserted).toBe(0);
|
|
|
|
const stored = await getPrismaClient().medicationIntakeEvent.findMany({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(stored).toHaveLength(1);
|
|
});
|
|
|
|
/**
|
|
* Migration 0273 — the idempotency key is unique PER USER.
|
|
*
|
|
* It carried a GLOBAL unique from 0001_init. Because the key is
|
|
* client-supplied, two people running the same client could mint the
|
|
* same one, and then the global constraint refused the second person's
|
|
* write outright: their dose was silently lost. The unscoped replay
|
|
* probe made it worse by resolving the key to the FIRST user's row and
|
|
* handing that stranger's row id back.
|
|
*
|
|
* These two cases are the whole contract: different users must not
|
|
* collide, and one user replaying their own key must still dedup.
|
|
*/
|
|
it("lets two different users post the SAME idempotency key — both doses land", async () => {
|
|
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
|
const prisma = getPrismaClient();
|
|
const SHARED_KEY = "ios-outbox-2026-05-16T08:00";
|
|
|
|
// User A posts first.
|
|
const first = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
medicationId: ownedMedId,
|
|
scheduledFor: "2026-05-16T08:00:00.000Z",
|
|
takenAt: "2026-05-16T08:02:00.000Z",
|
|
idempotencyKey: SHARED_KEY,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(first.status).toBe(200);
|
|
expect(
|
|
((await first.json()) as { data: { inserted: number } }).data.inserted,
|
|
).toBe(1);
|
|
|
|
// Now the OTHER user, with their own session and their own medication,
|
|
// posts the very same key.
|
|
const otherSession = await prisma.session.create({
|
|
data: {
|
|
userId: OTHER_USER_ID,
|
|
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", otherSession.id);
|
|
|
|
const second = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
medicationId: foreignMedId,
|
|
scheduledFor: "2026-05-16T08:00:00.000Z",
|
|
takenAt: "2026-05-16T08:03:00.000Z",
|
|
idempotencyKey: SHARED_KEY,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(second.status).toBe(200);
|
|
const secondJson = (await second.json()) as {
|
|
data: {
|
|
inserted: number;
|
|
duplicates: number;
|
|
entries: Array<{ status: string; id?: string }>;
|
|
};
|
|
};
|
|
// The second user's dose LANDS. Pre-0273 it was reported `duplicate`
|
|
// against the first user's row and never written.
|
|
expect(secondJson.data.inserted).toBe(1);
|
|
expect(secondJson.data.duplicates).toBe(0);
|
|
|
|
const mine = await prisma.medicationIntakeEvent.findMany({
|
|
where: { userId: OTHER_USER_ID },
|
|
select: { id: true, userId: true, idempotencyKey: true },
|
|
});
|
|
const theirs = await prisma.medicationIntakeEvent.findMany({
|
|
where: { userId: TEST_USER_ID },
|
|
select: { id: true },
|
|
});
|
|
expect(mine).toHaveLength(1);
|
|
expect(theirs).toHaveLength(1);
|
|
expect(mine[0].idempotencyKey).toBe(SHARED_KEY);
|
|
// And no id belonging to the first user was echoed to the second.
|
|
const echoed = secondJson.data.entries[0]?.id;
|
|
if (echoed) expect(echoed).not.toBe(theirs[0].id);
|
|
});
|
|
|
|
it("still dedups when the SAME user replays their own key", async () => {
|
|
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
|
const prisma = getPrismaClient();
|
|
const KEY = "ios-outbox-replay-001";
|
|
const body = {
|
|
entries: [
|
|
{
|
|
medicationId: ownedMedId,
|
|
scheduledFor: "2026-05-18T08:00:00.000Z",
|
|
takenAt: "2026-05-18T08:02:00.000Z",
|
|
idempotencyKey: KEY,
|
|
},
|
|
],
|
|
};
|
|
|
|
const first = await POST(makeRequest(body));
|
|
expect(
|
|
((await first.json()) as { data: { inserted: number } }).data.inserted,
|
|
).toBe(1);
|
|
|
|
const replay = await POST(makeRequest(body));
|
|
const replayJson = (await replay.json()) as {
|
|
data: {
|
|
inserted: number;
|
|
duplicates: number;
|
|
entries: Array<{ status: string; id?: string }>;
|
|
};
|
|
};
|
|
expect(replayJson.data.inserted).toBe(0);
|
|
expect(replayJson.data.duplicates).toBe(1);
|
|
|
|
const stored = await prisma.medicationIntakeEvent.findMany({
|
|
where: { userId: TEST_USER_ID, idempotencyKey: KEY },
|
|
select: { id: true },
|
|
});
|
|
expect(stored).toHaveLength(1);
|
|
// The replay resolves to the caller's OWN row.
|
|
expect(replayJson.data.entries[0]?.id).toBe(stored[0].id);
|
|
});
|
|
|
|
it("holds the per-user unique at the database, not only in the route", async () => {
|
|
const prisma = getPrismaClient();
|
|
const KEY = "db-level-shared-key";
|
|
|
|
await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
medicationId: ownedMedId,
|
|
scheduledFor: new Date("2026-05-19T08:00:00.000Z"),
|
|
takenAt: new Date("2026-05-19T08:00:00.000Z"),
|
|
source: "WEB",
|
|
idempotencyKey: KEY,
|
|
},
|
|
});
|
|
|
|
// Same key, different user — allowed now, refused before 0273.
|
|
await expect(
|
|
prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId: OTHER_USER_ID,
|
|
medicationId: foreignMedId,
|
|
scheduledFor: new Date("2026-05-19T08:00:00.000Z"),
|
|
takenAt: new Date("2026-05-19T08:00:00.000Z"),
|
|
source: "WEB",
|
|
idempotencyKey: KEY,
|
|
},
|
|
}),
|
|
).resolves.toBeTruthy();
|
|
|
|
// Same key, SAME user — still refused.
|
|
await expect(
|
|
prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
medicationId: ownedMedId,
|
|
scheduledFor: new Date("2026-05-20T08:00:00.000Z"),
|
|
takenAt: new Date("2026-05-20T08:00:00.000Z"),
|
|
source: "WEB",
|
|
idempotencyKey: KEY,
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({ code: "P2002" });
|
|
});
|
|
|
|
/**
|
|
* Migration 0273 step 1 — the reconciliation safety net.
|
|
*
|
|
* Against an intact database it can never fire: the global unique it
|
|
* replaces is strictly stronger than the per-user one, so no colliding
|
|
* pair can exist. It is there for a database whose global index was
|
|
* dropped by hand at some point. That means nothing else in this suite
|
|
* exercises it, so this case builds the collision on purpose and runs
|
|
* the SHIPPED SQL — read from the migration file, not a copy that could
|
|
* drift — against it.
|
|
*
|
|
* The whole thing runs inside a transaction that is rolled back, so the
|
|
* index surgery never escapes into the rest of the run.
|
|
*/
|
|
it("migration 0273 reconciles a pre-existing same-user collision without losing a dose", async () => {
|
|
const prisma = getPrismaClient();
|
|
const { readFileSync } = await import("node:fs");
|
|
const { join } = await import("node:path");
|
|
|
|
const sql = readFileSync(
|
|
join(
|
|
process.cwd(),
|
|
"prisma",
|
|
"migrations",
|
|
"0273_scope_intake_idempotency_key_per_user",
|
|
"migration.sql",
|
|
),
|
|
"utf8",
|
|
);
|
|
// Strip comments BEFORE splitting: the prose contains semicolons of
|
|
// its own, and splitting first would slice a statement in half.
|
|
const statements = sql
|
|
.split("\n")
|
|
.filter((line) => !line.trim().startsWith("--"))
|
|
.join("\n")
|
|
.split(";")
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
const safetyNet = statements.find((st) => st.startsWith("UPDATE"));
|
|
const createIndex = statements.find((st) =>
|
|
st.startsWith("CREATE UNIQUE INDEX"),
|
|
);
|
|
expect(
|
|
safetyNet,
|
|
"migration must carry the reconciliation UPDATE",
|
|
).toBeTruthy();
|
|
expect(createIndex, "migration must carry the per-user index").toBeTruthy();
|
|
|
|
const COLLIDING_KEY = "legacy-collision-key";
|
|
let observed: Array<{ id: string; idempotencyKey: string | null }> = [];
|
|
|
|
class Rollback extends Error {}
|
|
await expect(
|
|
prisma.$transaction(async (tx) => {
|
|
// Simulate the only database on which the collision is possible:
|
|
// one whose unique index was dropped by hand.
|
|
await tx.$executeRawUnsafe(
|
|
'DROP INDEX "medication_intake_events_user_id_idempotency_key_key"',
|
|
);
|
|
|
|
const earlier = await tx.medicationIntakeEvent.create({
|
|
data: {
|
|
id: "collide-earlier",
|
|
userId: TEST_USER_ID,
|
|
medicationId: ownedMedId,
|
|
scheduledFor: new Date("2026-05-21T08:00:00.000Z"),
|
|
takenAt: new Date("2026-05-21T08:00:00.000Z"),
|
|
source: "WEB",
|
|
idempotencyKey: COLLIDING_KEY,
|
|
createdAt: new Date("2026-05-21T08:00:00.000Z"),
|
|
},
|
|
});
|
|
const later = await tx.medicationIntakeEvent.create({
|
|
data: {
|
|
id: "collide-later",
|
|
userId: TEST_USER_ID,
|
|
medicationId: ownedMedId,
|
|
scheduledFor: new Date("2026-05-22T08:00:00.000Z"),
|
|
takenAt: new Date("2026-05-22T08:00:00.000Z"),
|
|
source: "WEB",
|
|
idempotencyKey: COLLIDING_KEY,
|
|
createdAt: new Date("2026-05-22T08:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
// Run the migration's own reconciliation, then its own index.
|
|
await tx.$executeRawUnsafe(safetyNet!);
|
|
await tx.$executeRawUnsafe(createIndex!);
|
|
|
|
observed = await tx.medicationIntakeEvent.findMany({
|
|
where: { id: { in: [earlier.id, later.id] } },
|
|
orderBy: { createdAt: "asc" },
|
|
select: { id: true, idempotencyKey: true },
|
|
});
|
|
throw new Rollback();
|
|
}),
|
|
).rejects.toBeInstanceOf(Rollback);
|
|
|
|
// Both doses survive — the migration never deletes or rewrites a row.
|
|
expect(observed).toHaveLength(2);
|
|
expect(observed[0]).toEqual({
|
|
id: "collide-earlier",
|
|
idempotencyKey: COLLIDING_KEY,
|
|
});
|
|
// Only the dedup HINT on the later row is cleared, which is what makes
|
|
// the unique index creatable.
|
|
expect(observed[1]).toEqual({
|
|
id: "collide-later",
|
|
idempotencyKey: null,
|
|
});
|
|
});
|
|
});
|