mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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.
269 lines
8.5 KiB
TypeScript
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");
|
|
});
|
|
});
|