Files
HealthLog/tests/integration/medication-write-idempotency.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

265 lines
9.5 KiB
TypeScript

/**
* The four medication create routes the native client replays out of its
* offline outbox, driven as routes against a real Postgres.
*
* Each case imports the shipped `POST` export and calls it twice with the same
* `Idempotency-Key` — the real `apiHandler`, the real `requireRecordAuth`, the
* real `withIdempotency`, the real Prisma writes. Nothing is reimplemented,
* because the thing under test happens ABOVE the handler body: a test that
* rebuilt the handler would prove its own copy of the wrapper works and would
* keep passing after a route dropped it.
*
* The client sends the header on every replay, so a lost success response used
* to cost the user a duplicate medication, a duplicate symptom entry, a
* duplicate pen in the supply count, or a second titration step. `POST /api/medications` had a dedupe of
* its own, but only for a MIRRORED create keyed on
* `(externalSource, externalId)`; a manually entered medication carries
* neither field and had nothing to collapse a retry onto — so the manual shape
* is what this file posts.
*
* Two assertions per route, and the second is the one that matters: the replay
* carries `X-Idempotent-Replay: true`, and the table holds exactly one row. A
* status-only check would pass against a handler that wrote twice and answered
* 201 twice. The medication case adds a third: a DIFFERENT key does create a
* second row, so the single row above is the key's doing and not some other
* uniqueness the fixture stumbled into.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-medication-idempotency-integration-1234567890abcdef";
// The side-effect note is encrypted at rest on the way in.
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const USER_ID = "user-medication-idempotency";
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),
})),
};
});
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "medication-idempotency",
email: "medication-idempotency@example.test",
timezone: "UTC",
},
});
const session = await getPrismaClient().session.create({
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
cookieJar.set("healthlog_session", session.id);
});
function post(path: string, body: unknown, idempotencyKey: string) {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key": idempotencyKey,
},
body: JSON.stringify(body),
});
}
/** A route whose params shape is its own business — see `sharing-delegable-routes`. */
type Handler = (
request: NextRequest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: { params: Promise<any> },
) => Promise<Response>;
/** A medication to hang the side-effect and inventory writes off. */
async function seedMedication(): Promise<string> {
const medication = await getPrismaClient().medication.create({
data: { userId: USER_ID, name: "Fixture", dose: "1 tab" },
});
return medication.id;
}
const MANUAL_MEDICATION = {
name: "Manually entered",
dose: "10 mg",
schedules: [
{ windowStart: "08:00", windowEnd: "08:30", timesOfDay: ["08:00"] },
],
};
describe("POST /api/medications — replayed manual create", () => {
it("replays the first response and creates exactly one medication", async () => {
const { POST } = await import("@/app/api/medications/route");
const first = await POST(
post("/api/medications", MANUAL_MEDICATION, "outbox-medication-1"),
);
expect(first.status).toBe(201);
const firstBody = await first.json();
const replay = await POST(
post("/api/medications", MANUAL_MEDICATION, "outbox-medication-1"),
);
expect(replay.status).toBe(201);
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
expect(await replay.json()).toEqual(firstBody);
const rows = await getPrismaClient().medication.findMany({
where: { userId: USER_ID },
});
expect(rows).toHaveLength(1);
});
it("still creates a second medication under a different key", async () => {
// The control on the case above: without it, a single row would also be
// the reading if the create had silently failed the second time for an
// unrelated reason.
const { POST } = await import("@/app/api/medications/route");
await POST(
post("/api/medications", MANUAL_MEDICATION, "outbox-medication-a"),
);
const second = await POST(
post("/api/medications", MANUAL_MEDICATION, "outbox-medication-b"),
);
expect(second.status).toBe(201);
expect(second.headers.get("X-Idempotent-Replay")).toBeNull();
const rows = await getPrismaClient().medication.findMany({
where: { userId: USER_ID },
});
expect(rows).toHaveLength(2);
});
});
describe("POST /api/medications/[id]/side-effects — replayed entry", () => {
it("replays the first response and logs exactly one entry", async () => {
const medicationId = await seedMedication();
const { POST } =
await import("@/app/api/medications/[id]/side-effects/route");
const body = { entry: "NAUSEA", severity: 3, notes: "after breakfast" };
const first = await (POST as Handler)(
post(
`/api/medications/${medicationId}/side-effects`,
body,
"outbox-side-effect-1",
),
{ params: Promise.resolve({ id: medicationId }) },
);
expect(first.status).toBe(201);
const firstBody = await first.json();
const replay = await (POST as Handler)(
post(
`/api/medications/${medicationId}/side-effects`,
body,
"outbox-side-effect-1",
),
{ params: Promise.resolve({ id: medicationId }) },
);
expect(replay.status).toBe(201);
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
expect(await replay.json()).toEqual(firstBody);
const rows = await getPrismaClient().medicationSideEffect.findMany({
where: { userId: USER_ID, medicationId },
});
expect(rows).toHaveLength(1);
});
});
describe("POST /api/medications/[id]/inventory — replayed container", () => {
it("replays the first response and registers exactly one container", async () => {
const medicationId = await seedMedication();
const { POST } = await import("@/app/api/medications/[id]/inventory/route");
const body = { unitsTotal: 30, containerType: "BLISTER" };
const first = await (POST as Handler)(
post(
`/api/medications/${medicationId}/inventory`,
body,
"outbox-inventory-1",
),
{ params: Promise.resolve({ id: medicationId }) },
);
expect(first.status).toBe(201);
const firstBody = await first.json();
const replay = await (POST as Handler)(
post(
`/api/medications/${medicationId}/inventory`,
body,
"outbox-inventory-1",
),
{ params: Promise.resolve({ id: medicationId }) },
);
expect(replay.status).toBe(201);
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
expect(await replay.json()).toEqual(firstBody);
const rows = await getPrismaClient().medicationInventoryItem.findMany({
where: { userId: USER_ID, medicationId },
});
expect(rows).toHaveLength(1);
});
});
describe("POST /api/medications/[id]/glp1 — replayed titration step", () => {
it("replays the first response and writes exactly one dose change", async () => {
const medicationId = await seedMedication();
const { POST } = await import("@/app/api/medications/[id]/glp1/route");
const body = {
doseChange: {
effectiveFrom: "2026-09-01T00:00:00Z",
doseValue: 2.5,
doseUnit: "mg",
note: "stepped up after four weeks",
},
};
const first = await (POST as Handler)(
post(`/api/medications/${medicationId}/glp1`, body, "outbox-glp1-1"),
{ params: Promise.resolve({ id: medicationId }) },
);
expect(first.status).toBe(201);
const firstBody = await first.json();
const replay = await (POST as Handler)(
post(`/api/medications/${medicationId}/glp1`, body, "outbox-glp1-1"),
{ params: Promise.resolve({ id: medicationId }) },
);
expect(replay.status).toBe(201);
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
expect(await replay.json()).toEqual(firstBody);
// The titration row carries no natural key at all, so without the wrapper
// the replay would leave the medication with two dose changes on the same
// day and the runway estimate reading off the wrong one.
const rows = await getPrismaClient().medicationDoseChange.findMany({
where: { medicationId },
});
expect(rows).toHaveLength(1);
});
});