Files
HealthLog/tests/integration/partial-unique-live-rows.test.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

269 lines
8.5 KiB
TypeScript

/**
* v1.12.1 — partial live-row unique index on MedicationIntakeEvent
* (migration 0121) + MoodEntry external-id dedup key (migration 0122).
*
* These pin the delete-then-resync / re-import contracts on real
* Postgres, against the migrations replayed by `global-setup.ts`.
*
* 1. MedicationIntakeEvent — the real re-take path through
* `applyCanonicalSlotWrite`: take a slot, soft-delete it (user
* "forgot this"), then re-take the SAME canonical slot. Pre-0121
* the create P2002'd against the tombstone and the catch re-found
* only live rows (none) and re-threw — the re-take 500'd. With the
* partial live-row unique it re-creates cleanly.
*
* 2. MoodEntry — a moodLog re-import with the SAME stable `id` but a
* re-zoned `moodLoggedAt` updates one row (idempotent on
* `(userId, source, externalId)`), while two imports WITHOUT an id
* still land two rows on the legacy wall-clock key.
*
* Note: Measurement is intentionally NOT made partial-unique (its
* compound-key writes use `prisma.upsert` → native ON CONFLICT, which
* Postgres cannot arbiter against a partial unique). See migration 0121's
* header for the full rationale.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { applyCanonicalSlotWrite } from "@/lib/medications/scheduling/slot-upsert";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
describe("MedicationIntakeEvent — re-take after delete (v1.12.1 / 0121)", () => {
it("re-takes a previously-deleted slot without 500-ing", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "partial-intake",
email: "partial-intake@example.test",
role: "USER",
},
});
const med = await prisma.medication.create({
data: { userId: user.id, name: "Ramipril", dose: "5mg" },
});
const slot = new Date("2026-06-03T06:00:00.000Z");
// First take — fresh slot.
const firstTake = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: med.id,
canonicalSlot: slot,
takenAt: new Date("2026-06-03T06:05:00.000Z"),
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
});
expect(firstTake.outcome).toBe("inserted");
expect(firstTake.row.takenAt).not.toBeNull();
// User deletes the take (soft-delete the slot row).
await prisma.medicationIntakeEvent.update({
where: { id: firstTake.row.id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
// Re-take the SAME canonical slot. Pre-0121 this threw the original
// P2002 (tombstone occupied (user, med, scheduled_for, source)).
const reTake = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: med.id,
canonicalSlot: slot,
takenAt: new Date("2026-06-03T06:10:00.000Z"),
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
});
expect(reTake.outcome).toBe("inserted");
expect(reTake.row.id).not.toBe(firstTake.row.id);
expect(reTake.row.takenAt).not.toBeNull();
// Exactly one LIVE slot row; the tombstone remains.
const live = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
medicationId: med.id,
scheduledFor: slot,
deletedAt: null,
},
});
expect(live).toHaveLength(1);
expect(live[0].id).toBe(reTake.row.id);
// The live-row partial unique still forbids a SECOND live duplicate
// on the same (user, med, slot, source).
await expect(
prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: med.id,
scheduledFor: slot,
source: "WEB",
},
}),
).rejects.toThrow();
});
});
describe("MoodEntry — external-id re-import idempotency (v1.12.1 / 0122)", () => {
function webhookRequest(secret: string, body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/integrations/moodlog/webhook",
{
method: "POST",
headers: {
"content-type": "application/json",
"x-webhook-secret": secret,
},
body: JSON.stringify(body),
},
);
}
it("re-import with the same stable id but re-zoned time updates one row", async () => {
const prisma = getPrismaClient();
const secret = "mood-secret-idempotent";
const user = await prisma.user.create({
data: {
username: "mood-external-id",
email: "mood-external-id@example.test",
role: "USER",
moodLogEnabled: true,
// Legacy plaintext secret — readMoodLogSecret tolerates it.
moodLogWebhookSecret: secret,
},
});
const { POST } =
await import("@/app/api/integrations/moodlog/webhook/route");
// First import — entry carries a stable upstream id.
const r1 = await POST(
webhookRequest(secret, {
event: "mood.created",
timestamp: "2026-06-04T10:00:00.000Z",
entry: {
id: "daylio-row-42",
date: "2026-06-04",
time: "2026-06-04T10:00:00.000Z",
mood: "GUT",
score: 4,
},
}),
);
expect(r1.status).toBe(200);
// Re-import — SAME id, but a re-rounded / re-zoned time (and the
// upstream re-scored the mood). Pre-0122 this minted a 2nd row on
// the differing (date, moodLoggedAt) key.
const r2 = await POST(
webhookRequest(secret, {
event: "mood.updated",
timestamp: "2026-06-04T10:30:00.000Z",
entry: {
id: "daylio-row-42",
date: "2026-06-04",
time: "2026-06-04T08:00:00.000Z",
mood: "SUPER_GUT",
score: 5,
},
}),
);
expect(r2.status).toBe(200);
const rows = await prisma.moodEntry.findMany({
where: { userId: user.id, source: "MOODLOG" },
});
expect(rows).toHaveLength(1);
expect(rows[0].externalId).toBe("daylio-row-42");
// The update applied (mood + score + re-zoned time).
expect(rows[0].mood).toBe("SUPER_GUT");
expect(rows[0].score).toBe(5);
});
it("entries without an id still dedup on the legacy wall-clock key", async () => {
const prisma = getPrismaClient();
const secret = "mood-secret-legacy";
const user = await prisma.user.create({
data: {
username: "mood-legacy-key",
email: "mood-legacy-key@example.test",
role: "USER",
moodLogEnabled: true,
moodLogWebhookSecret: secret,
},
});
const { POST } =
await import("@/app/api/integrations/moodlog/webhook/route");
// Two entries, no id, DIFFERENT times → two rows (legacy behaviour
// preserved). A re-emit at the SAME time updates in place.
await POST(
webhookRequest(secret, {
event: "mood.created",
timestamp: "2026-06-05T09:00:00.000Z",
entry: {
date: "2026-06-05",
time: "2026-06-05T09:00:00.000Z",
mood: "OKAY",
score: 3,
},
}),
);
await POST(
webhookRequest(secret, {
event: "mood.created",
timestamp: "2026-06-05T21:00:00.000Z",
entry: {
date: "2026-06-05",
time: "2026-06-05T21:00:00.000Z",
mood: "GUT",
score: 4,
},
}),
);
// Re-emit the first at the exact same time → in-place update.
await POST(
webhookRequest(secret, {
event: "mood.updated",
timestamp: "2026-06-05T09:00:00.000Z",
entry: {
date: "2026-06-05",
time: "2026-06-05T09:00:00.000Z",
mood: "SUPER_GUT",
score: 5,
},
}),
);
const rows = await prisma.moodEntry.findMany({
where: { userId: user.id, source: "MOODLOG" },
orderBy: { moodLoggedAt: "asc" },
});
expect(rows).toHaveLength(2);
expect(rows.every((r) => r.externalId === null)).toBe(true);
// The morning row was updated in place by the same-time re-emit.
expect(rows[0].mood).toBe("SUPER_GUT");
expect(rows[1].mood).toBe("GUT");
});
});