/** * The eleven delegated writes, driven as routes, against real Postgres. * * Same construction as `sharing-delegable-routes.test.ts` and for the same * reason: every case imports the shipped `POST` export and calls it, so the * real `apiHandler`, the real `requireRecordAuth`, the real grant table and * the real Prisma writes are what answer. A test that rebuilt a handler would * be testing its own copy of a substitution that happens above the handler * body, and would keep passing after the route stopped performing it. * * The grant is minted through `inviteGrant` + `acceptGrant` rather than * hand-planted, because "a WRITE grant exists" and "a WRITE grant can be * created by the invitation flow this release ships" are different claims and * the second is the one a caregiver depends on. * * Five questions per verb: * * 1. a delegate with a live WRITE grant writes into the OWNER's record — * the row lands under the owner and NOT under the delegate; * 2. the same delegate under a READ grant is refused * `sharing.access.denied`, and no row is written; * 3. a caller who was never granted anything is refused the same way; * 4. after revocation the very NEXT request is refused — no re-login, no * cache window; * 5. a caller who has not switched gets exactly what they got before. * * (5) is the regression that matters most. Eleven verbs changed; a person who * shares nothing must not be able to tell. * * Beyond the five, three properties that are specific rather than structural: * the audit row carries `userId = owner` AND `actorUserId = delegate` (the * pipe, not just the ends — `actingActorFor` reads the wide-event context, so * a stamped row proves the context was set); the side-effect rate bucket keys * on the ACTOR; and both intake routes hand the OWNER and the ACTOR to the * notification helper rather than one id twice. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; 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), })); /** * The one collaborator this file stubs, and only at the seam it is testing. * * What the helper DOES — the cascade, the preference row, the ledger, the * self-refusal, the snooze refusal — is proved end to end against the real * dispatcher in `delegated-intake-notification.test.ts`. What that file cannot * see is whether the two intake routes hand it the owner and the actor the * right way round, because the helper's own self-refusal makes an * `ownerId === actorId` mix-up look exactly like a self-write: silent. So this * file asserts the arguments the routes pass, which is the half of the pipe * the other file leaves open. */ const notifyDelegatedIntake = vi.fn(async (_input: unknown) => {}); vi.mock("@/lib/notifications/delegated-intake", () => ({ notifyDelegatedIntake: (input: unknown) => notifyDelegatedIntake(input), })); let counter = 0; async function makeUser(label: string) { const suffix = `${label}-${counter++}`; return getPrismaClient().user.create({ data: { username: `dw-${suffix}`, email: `dw-${suffix}@example.test`, displayName: `DW ${suffix}`, role: "USER", timezone: "Europe/Berlin", }, }); } async function signIn(userId: string) { const session = await getPrismaClient().session.create({ data: { userId, expiresAt: new Date(Date.now() + 60_000) }, }); cookieJar.set("healthlog_session", session.id); return session; } /** * A live grant at the named level, accepted, with the delegate's session * already inside the owner's record. * * Through `inviteGrant` / `acceptGrant` — the shipped transitions — so a * release that could not actually mint a WRITE grant would fail here rather * than pass against a row this file wrote itself. */ async function switchInto( ownerId: string, delegateId: string, access: "READ" | "WRITE" | "MANAGE", ) { const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); const invited = await inviteGrant({ grantorId: ownerId, granteeId: delegateId, access, scope: null, }); const grant = await acceptGrant({ grantId: invited.id, granteeId: delegateId, }); expect(grant.access).toBe(access); expect(grant.acceptedAt).not.toBeNull(); const session = await signIn(delegateId); await switchSessionTo(session.id, ownerId); return { grant, session }; } /** A session inside a record nobody ever shared with this caller. */ async function claimWithoutGrant(ownerId: string, callerId: string) { const session = await signIn(callerId); await switchSessionTo(session.id, ownerId); } async function revoke(grantId: string, ownerId: string) { const { revokeGrant } = await import("@/lib/sharing/grants"); await revokeGrant({ grantId, grantorId: ownerId }); } type Handler = ( request: NextRequest, // eslint-disable-next-line @typescript-eslint/no-explicit-any context: { params: Promise }, ) => Promise; function postRequest(url: string, body: unknown): NextRequest { return new NextRequest(`http://localhost${url}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); } async function post( handler: Handler, url: string, body: unknown, params: Record = {}, ): Promise { return handler(postRequest(url, body), { params: Promise.resolve(params) }); } async function payload(response: Response): Promise<{ data: unknown; error: unknown; meta?: { errorCode?: string }; }> { return response.json(); } /** The stable refusal, asserted by code rather than by status alone. */ async function expectDenied(response: Response) { expect(response.status).toBe(403); expect((await payload(response)).meta?.errorCode).toBe( "sharing.access.denied", ); } const ISO = "2026-07-15T08:00:00.000Z"; /* -------------------------------------------------------------------------- */ /* The shape of a delegated-write case */ /* -------------------------------------------------------------------------- */ interface WriteCase { /** Rows the write depends on, created under the record it will land in. */ prepare?: (recordOwnerId: string) => Promise; /** Drive the shipped POST export against the current session. */ call: (ctx: C) => Promise; /** The status a successful write answers with. */ ok: number; /** The action the happy path files through `auditLog`. */ auditAction: string; /** How many rows this verb has written under a given account. */ count: (userId: string) => Promise; } /** * The five legs, run against one verb. * * `prepare` runs against whichever account the write is aimed at, so the * delegated legs build their prerequisites under the OWNER — a delegate * pointing at a metric or a medication of their own is a different test, and * the read suite already pins that it 404s. */ function writeContract(name: string, c: WriteCase) { const prepare = c.prepare ?? (async () => undefined as C); describe(name, () => { it("writes into the owner's record under a live WRITE grant", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const ctx = await prepare(owner.id); await switchInto(owner.id, delegate.id, "WRITE"); const response = await c.call(ctx); expect(response.status).toBe(c.ok); // Under the owner, and under nobody else. Asserting the owner's count // alone would pass on a handler that wrote a row to each account. expect(await c.count(owner.id)).toBe(1); expect(await c.count(delegate.id)).toBe(0); }); it("files the audit row under the owner and names the delegate as actor", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const ctx = await prepare(owner.id); await switchInto(owner.id, delegate.id, "WRITE"); expect((await c.call(ctx)).status).toBe(c.ok); const row = await getPrismaClient().auditLog.findFirst({ where: { action: c.auditAction }, orderBy: { createdAt: "desc" }, }); // The row exists at all — the audit assertion below is worthless // against a null, and `toBe(undefined)` would pass on one. expect(row, `no ${c.auditAction} row was written`).not.toBeNull(); expect(row?.userId).toBe(owner.id); expect(row?.actorUserId).toBe(delegate.id); }); it("refuses the same delegate under a READ grant, and writes nothing", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const ctx = await prepare(owner.id); await switchInto(owner.id, delegate.id, "READ"); await expectDenied(await c.call(ctx)); expect(await c.count(owner.id)).toBe(0); expect(await c.count(delegate.id)).toBe(0); }); it("refuses a caller who names a record they were never granted", async () => { const owner = await makeUser("owner"); const stranger = await makeUser("stranger"); const ctx = await prepare(owner.id); await claimWithoutGrant(owner.id, stranger.id); await expectDenied(await c.call(ctx)); expect(await c.count(owner.id)).toBe(0); expect(await c.count(stranger.id)).toBe(0); }); it("refuses the next request after the grant is revoked", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const ctx = await prepare(owner.id); const { grant } = await switchInto(owner.id, delegate.id, "WRITE"); expect((await c.call(ctx)).status).toBe(c.ok); // No sign-out, no new session, no cache flush. The same browser, the // next request. The refusal comes from the resolver, above the handler // body, so replaying the identical request is the sharpest form of the // question: the ONLY thing that changed is the grant. await revoke(grant.id, owner.id); await expectDenied(await c.call(ctx)); expect(await c.count(owner.id)).toBe(1); }); it("is unchanged for a caller who has not switched", async () => { const plain = await makeUser("plain"); const ctx = await prepare(plain.id); await signIn(plain.id); const response = await c.call(ctx); expect(response.status).toBe(c.ok); expect(await c.count(plain.id)).toBe(1); // And the row is filed as their own act: `actorUserId` stays null, which // is what every row written before the column existed already means. const row = await getPrismaClient().auditLog.findFirst({ where: { action: c.auditAction }, orderBy: { createdAt: "desc" }, }); expect(row?.userId).toBe(plain.id); expect(row?.actorUserId).toBeNull(); }); }); } beforeEach(async () => { await truncateAllTables(getPrismaClient()); cookieJar.clear(); headerJar.clear(); notifyDelegatedIntake.mockClear(); }); /* -------------------------------------------------------------------------- */ /* 1 — measurements, single and batch */ /* -------------------------------------------------------------------------- */ const countMeasurements = (userId: string) => getPrismaClient().measurement.count({ where: { userId } }); writeContract("POST /api/measurements — single", { call: async () => { const { POST } = await import("@/app/api/measurements/route"); return post(POST as Handler, "/api/measurements", { type: "WEIGHT", value: 81, measuredAt: ISO, }); }, ok: 201, auditAction: "measurement.create", count: countMeasurements, }); writeContract("POST /api/measurements — batch", { call: async () => { const { POST } = await import("@/app/api/measurements/route"); return post(POST as Handler, "/api/measurements", [ { type: "WEIGHT", value: 81, measuredAt: ISO }, ]); }, ok: 201, auditAction: "measurement.create.batch", count: countMeasurements, }); /* -------------------------------------------------------------------------- */ /* 2 — labs, and the biomarker the free-text path mints */ /* -------------------------------------------------------------------------- */ writeContract("POST /api/labs", { call: async () => { const { POST } = await import("@/app/api/labs/route"); return post(POST as Handler, "/api/labs", { analyte: "HbA1c", value: 5.2, unit: "mmol/L", takenAt: ISO, }); }, ok: 201, auditAction: "labResult.create", count: (userId) => getPrismaClient().labResult.count({ where: { userId } }), }); describe("POST /api/labs — the marker the free-text path mints", () => { it("joins the OWNER's catalogue, not the delegate's", async () => { // A result added to somebody's record whose marker landed on the helper's // account would leave the owner with a reading they cannot chart. The // mint is a second write, under a second table, on the same request. const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await switchInto(owner.id, delegate.id, "WRITE"); const { POST } = await import("@/app/api/labs/route"); const response = await post(POST as Handler, "/api/labs", { analyte: "Ferritin", value: 120, unit: "µg/L", takenAt: ISO, }); expect(response.status).toBe(201); const markers = await getPrismaClient().biomarker.findMany({ select: { userId: true, name: true }, }); expect(markers).toEqual([{ userId: owner.id, name: "Ferritin" }]); const result = await getPrismaClient().labResult.findFirstOrThrow({}); expect(result.userId).toBe(owner.id); expect(result.biomarkerId).not.toBeNull(); }); }); /* -------------------------------------------------------------------------- */ /* 3 — biomarkers */ /* -------------------------------------------------------------------------- */ writeContract("POST /api/biomarkers", { call: async () => { const { POST } = await import("@/app/api/biomarkers/route"); return post(POST as Handler, "/api/biomarkers", { name: "Ferritin", unit: "µg/L", }); }, ok: 201, auditAction: "biomarker.create", count: (userId) => getPrismaClient().biomarker.count({ where: { userId } }), }); /* -------------------------------------------------------------------------- */ /* 4 + 5 — allergies and family history, admitted at MANAGE */ /* -------------------------------------------------------------------------- */ /** * The MANAGE contract, and why the WRITE one will not do. * * `writeContract` above asserts that a WRITE grant writes. A route that * declares `requireRecordAuth("manage", …)` must REFUSE a WRITE grant, so the * two contracts differ in their first leg and agree on every other: the row * lands under the owner and under nobody else, the audit row is filed under the * owner with the delegate named as actor, the refusals are the stable * `sharing.access.denied`, revocation takes effect on the very next request, * and an unswitched owner is unaffected. * * Two legs are new coverage rather than a rewrite, and they are the reason this * block exists. Nothing in the repository drove a MANAGE delegate against * either of these routes before, and nothing drove a Guardian against them at * all — the routes had admitted the write since the level shipped while the * only form that posts to them was classified unavailable, so the permission * had no reachable caller. Plan 13 opened the surface; these are the legs that * say the write it leads to actually lands. * * What this block replaces is a `refusedWriteContract` whose legs asserted * `sharing.not_permitted` — the refusal a route that declares NO sharing mode * returns. These routes declare one, so a READ or WRITE delegate gets * `sharing.access.denied` instead, and those four assertions had been failing * against the shipped routes for a release. */ function manageWriteContract(name: string, c: WriteCase) { const prepare = c.prepare ?? (async () => undefined as C); describe(name, () => { it("writes into the owner's record under a live MANAGE grant", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const ctx = await prepare(owner.id); await switchInto(owner.id, delegate.id, "MANAGE"); const response = await c.call(ctx); expect(response.status).toBe(c.ok); // Under the owner, and under nobody else. expect(await c.count(owner.id)).toBe(1); expect(await c.count(delegate.id)).toBe(0); }); it("writes into a managed profile for its Guardian", async () => { // The population the surface exists for, driven through the shipped // creation service rather than a hand-planted grant: a Guardian is a // MANAGE holder on a credential-less record, and this is the only place // in the suite that puts one against these routes. const guardian = await makeUser("guardian"); const { createManagedProfile } = await import("@/lib/managed-profiles/create"); const { profile } = await createManagedProfile({ creatorId: guardian.id, displayName: "Managed profile", dateOfBirth: null, locale: "de", timezone: "Europe/Berlin", }); const ctx = await prepare(profile.id); const session = await signIn(guardian.id); await switchSessionTo(session.id, profile.id); expect((await c.call(ctx)).status).toBe(c.ok); expect(await c.count(profile.id)).toBe(1); expect(await c.count(guardian.id)).toBe(0); }); it("files the audit row under the owner and names the delegate as actor", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const ctx = await prepare(owner.id); await switchInto(owner.id, delegate.id, "MANAGE"); expect((await c.call(ctx)).status).toBe(c.ok); const row = await getPrismaClient().auditLog.findFirst({ where: { action: c.auditAction }, orderBy: { createdAt: "desc" }, }); expect(row, `no ${c.auditAction} row was written`).not.toBeNull(); expect(row?.userId).toBe(owner.id); expect(row?.actorUserId).toBe(delegate.id); }); for (const access of ["READ", "WRITE"] as const) { it(`refuses a ${access} delegate with sharing.access.denied, and writes nothing`, async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const ctx = await prepare(owner.id); await switchInto(owner.id, delegate.id, access); // The insufficient-level refusal, not the undeclared-mode one. The // route names a mode; what the delegate lacks is the level. await expectDenied(await c.call(ctx)); expect(await c.count(owner.id)).toBe(0); expect(await c.count(delegate.id)).toBe(0); }); } it("refuses a caller who names a record they were never granted", async () => { const owner = await makeUser("owner"); const stranger = await makeUser("stranger"); const ctx = await prepare(owner.id); await claimWithoutGrant(owner.id, stranger.id); await expectDenied(await c.call(ctx)); expect(await c.count(owner.id)).toBe(0); expect(await c.count(stranger.id)).toBe(0); }); it("refuses the next request after the grant is revoked", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const ctx = await prepare(owner.id); const { grant } = await switchInto(owner.id, delegate.id, "MANAGE"); expect((await c.call(ctx)).status).toBe(c.ok); await revoke(grant.id, owner.id); await expectDenied(await c.call(ctx)); expect(await c.count(owner.id)).toBe(1); }); it("is unchanged for a caller who has not switched", async () => { const plain = await makeUser("plain"); const ctx = await prepare(plain.id); await signIn(plain.id); const response = await c.call(ctx); expect(response.status).toBe(c.ok); expect(await c.count(plain.id)).toBe(1); const row = await getPrismaClient().auditLog.findFirst({ where: { action: c.auditAction }, orderBy: { createdAt: "desc" }, }); expect(row?.userId).toBe(plain.id); expect(row?.actorUserId).toBeNull(); }); }); } manageWriteContract("POST /api/allergies", { call: async () => { const { POST } = await import("@/app/api/allergies/route"); return post(POST as Handler, "/api/allergies", { substance: "Penicillin", category: "MEDICATION", severity: "SEVERE", }); }, ok: 201, auditAction: "allergy.create", count: (userId) => getPrismaClient().allergy.count({ where: { userId } }), }); manageWriteContract("POST /api/family-history", { call: async () => { const { POST } = await import("@/app/api/family-history/route"); return post(POST as Handler, "/api/family-history", { relationship: "MOTHER", condition: "Type 2 diabetes", }); }, ok: 201, auditAction: "family-history.create", count: (userId) => getPrismaClient().familyHistoryEntry.count({ where: { userId } }), }); /* -------------------------------------------------------------------------- */ /* 6 — illness episodes, and the module gate that follows the record */ /* -------------------------------------------------------------------------- */ writeContract("POST /api/illness/episodes", { call: async () => { const { POST } = await import("@/app/api/illness/episodes/route"); return post(POST as Handler, "/api/illness/episodes", { label: "Influenza", type: "INFECTION", }); }, ok: 201, auditAction: "illness.episode.create", count: (userId) => getPrismaClient().illnessEpisode.count({ where: { userId } }), }); describe("POST /api/illness/episodes — the module gate", () => { it("refuses when the OWNER switched illness tracking off, whatever the delegate set", async () => { // The recorded decision: the module map governs which parts of a RECORD // exist, so it is read off the record. A delegate who runs the module on // their own account does not thereby gain an episode surface inside // somebody else's. const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await getPrismaClient().user.update({ where: { id: owner.id }, data: { modulePreferencesJson: { illness: false } }, }); await getPrismaClient().user.update({ where: { id: delegate.id }, data: { modulePreferencesJson: { illness: true } }, }); await switchInto(owner.id, delegate.id, "WRITE"); const { POST } = await import("@/app/api/illness/episodes/route"); const response = await post(POST as Handler, "/api/illness/episodes", { label: "Influenza", type: "INFECTION", }); expect(response.status).toBe(403); expect((await payload(response)).meta?.errorCode).toBe("illness.disabled"); expect( await getPrismaClient().illnessEpisode.count({ where: { userId: owner.id }, }), ).toBe(0); }); }); /* -------------------------------------------------------------------------- */ /* 7 — custom-metric entries, the third that is NOT delegable */ /* -------------------------------------------------------------------------- */ /** * The contract for a route that declares NO sharing mode at all. * * Its legs assert `sharing.not_permitted` — the refusal the carrier collects * before any grant is consulted — which is a different fact from * `sharing.access.denied` and is what makes the pair worth keeping apart. A * route that names a mode and refuses a level answers the second; a route that * names nothing answers the first, and a WRITE grant makes no difference to it. * * v1.37.0 — allergies and family history left this helper for * `manageWriteContract` above, in the same diff as the surface that made their * admitted writes reachable. One caller remains. */ function refusedWriteContract( name: string, c: { /** Seed whatever the write needs, owned by the RECORD. Mirrors `writeContract`. */ prepare?: (recordOwnerId: string) => Promise; call: (ctx: C) => Promise; ok: number; count: (userId: string) => Promise; }, ) { const prepare = c.prepare ?? (async () => undefined as C); describe(name, () => { for (const access of ["READ", "WRITE"] as const) { it(`refuses a delegate holding a ${access} grant, and writes nothing`, async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const ctx = await prepare(owner.id); await switchInto(owner.id, delegate.id, access); const response = await c.call(ctx); // The undeclared-mode refusal, not the no-grant one: the route names // no sharing mode at all, so the carrier is refused before any grant // is consulted. A WRITE grant makes no difference, which is the point. expect(response.status).toBe(403); expect((await payload(response)).meta?.errorCode).toBe( "sharing.not_permitted", ); // Neither account, not just not the owner's — a handler that fell // back to the caller would have written a row somewhere. expect(await c.count(owner.id)).toBe(0); expect(await c.count(delegate.id)).toBe(0); }); } it("still lets the owner write it themselves", async () => { const owner = await makeUser("owner"); const ctx = await prepare(owner.id); await signIn(owner.id); const response = await c.call(ctx); expect(response.status).toBe(c.ok); expect(await c.count(owner.id)).toBe(1); }); }); } /** * The third withdrawal, one release after the other two, and the delay is what * makes it worth a paragraph: the rule that removed allergies and family * history fitted this route exactly and was applied to two of the three. * * The only surface that posts a tracked value is the entry form on * `/custom-metrics/{id}`, which is not a shared-record destination — the shell * renders "not part of what was shared" there before the form mounts, and * `custom-metric-list.tsx` said so in its own comment while returning null. So * the permission shipped named on the consent screen, in six languages, with * nothing behind it. * * The GET arm stays delegable and the read suite pins it. Re-admitting means * the same three moves the block above names, in the same diff as the surface * that makes it reachable. */ refusedWriteContract("POST /api/custom-metrics/[id]/entries", { prepare: async (recordOwnerId) => { const metric = await getPrismaClient().customMetric.create({ data: { userId: recordOwnerId, name: "Water", unit: "ml" }, }); return metric.id; }, call: async (metricId) => { const { POST } = await import("@/app/api/custom-metrics/[id]/entries/route"); return post( POST as Handler, `/api/custom-metrics/${metricId}/entries`, { value: 250, measuredAt: ISO }, { id: metricId }, ); }, ok: 201, count: (userId) => getPrismaClient().customMetricEntry.count({ where: { userId } }), }); /* -------------------------------------------------------------------------- */ /* 8 — medication side effects, and the actor-keyed bucket */ /* -------------------------------------------------------------------------- */ async function seedMedication(userId: string, name = "Statin") { return getPrismaClient().medication.create({ data: { userId, name, dose: "10mg" }, }); } writeContract("POST /api/medications/[id]/side-effects", { prepare: async (recordOwnerId) => (await seedMedication(recordOwnerId)).id, call: async (medicationId) => { const { POST } = await import("@/app/api/medications/[id]/side-effects/route"); return post( POST as Handler, `/api/medications/${medicationId}/side-effects`, { entry: "NAUSEA", severity: 3 }, { id: medicationId }, ); }, ok: 201, auditAction: "medication.sideEffect.create", count: (userId) => getPrismaClient().medicationSideEffect.count({ where: { userId } }), }); describe("POST /api/medications/[id]/side-effects — the bucket follows the actor", () => { it("spends the delegate's allowance in ONE bucket across two records", async () => { // The single condition on which this verb was admitted. On the record key // a delegate could exhaust the owner's allowance and lock them out of // their own log, and could collect a fresh one by switching records. The // `rate_limits` rows are read from the table rather than inferred from a // 429, because a key that is merely wrong still answers 201. const first = await makeUser("owner"); const second = await makeUser("owner"); const delegate = await makeUser("delegate"); const firstMed = await seedMedication(first.id, "First"); const secondMed = await seedMedication(second.id, "Second"); const { POST } = await import("@/app/api/medications/[id]/side-effects/route"); await switchInto(first.id, delegate.id, "WRITE"); expect( ( await post( POST as Handler, `/api/medications/${firstMed.id}/side-effects`, { entry: "NAUSEA", severity: 3 }, { id: firstMed.id }, ) ).status, ).toBe(201); await switchInto(second.id, delegate.id, "WRITE"); expect( ( await post( POST as Handler, `/api/medications/${secondMed.id}/side-effects`, { entry: "NAUSEA", severity: 2 }, { id: secondMed.id }, ) ).status, ).toBe(201); const rows = await getPrismaClient().rateLimit.findMany({ where: { key: { startsWith: "medication-side-effect:post:" } }, }); // One bucket, the delegate's, holding both requests. Two buckets — one per // record — is the failure this asserts against, and it looks identical // from the response side. expect(rows.map((r) => r.key)).toEqual([ `medication-side-effect:post:${delegate.id}`, ]); expect(rows[0]?.count).toBe(2); }); it("still keys on the caller when nobody has switched", async () => { const plain = await makeUser("plain"); const med = await seedMedication(plain.id); await signIn(plain.id); const { POST } = await import("@/app/api/medications/[id]/side-effects/route"); expect( ( await post( POST as Handler, `/api/medications/${med.id}/side-effects`, { entry: "NAUSEA", severity: 1 }, { id: med.id }, ) ).status, ).toBe(201); const rows = await getPrismaClient().rateLimit.findMany({ where: { key: { startsWith: "medication-side-effect:post:" } }, }); expect(rows.map((r) => r.key)).toEqual([ `medication-side-effect:post:${plain.id}`, ]); }); }); /* -------------------------------------------------------------------------- */ /* 9 — adding a medication, with its nested schedule */ /* -------------------------------------------------------------------------- */ writeContract("POST /api/medications", { call: async () => { const { POST } = await import("@/app/api/medications/route"); return post(POST as Handler, "/api/medications", { name: "Metformin", dose: "500mg", schedules: [{ windowStart: "08:00", windowEnd: "10:00" }], }); }, ok: 201, auditAction: "medication.create", count: (userId) => getPrismaClient().medication.count({ where: { userId } }), }); describe("POST /api/medications — the nested schedule", () => { it("lands under the owner, because a medication nobody scheduled reminds nobody", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await switchInto(owner.id, delegate.id, "WRITE"); const { POST } = await import("@/app/api/medications/route"); const response = await post(POST as Handler, "/api/medications", { name: "Metformin", dose: "500mg", schedules: [{ windowStart: "08:00", windowEnd: "10:00" }], }); expect(response.status).toBe(201); const medication = await getPrismaClient().medication.findFirstOrThrow({ include: { schedules: true }, }); expect(medication.userId).toBe(owner.id); expect(medication.schedules).toHaveLength(1); expect(medication.schedules[0]?.windowStart).toBe("08:00"); // The schedule hangs off the medication rather than off a user id, so the // only way it could reach the wrong account is through the medication — // asserted above — but the count under the delegate is the cheap proof // that nothing was mirrored. expect( await getPrismaClient().medication.count({ where: { userId: delegate.id }, }), ).toBe(0); }); }); /* -------------------------------------------------------------------------- */ /* 10 / 11 — marking a dose */ /* -------------------------------------------------------------------------- */ /** A medication of `userId` with one pending slot on it. */ async function seedPendingDose(userId: string) { const medication = await seedMedication(userId, "Insulin"); const event = await getPrismaClient().medicationIntakeEvent.create({ data: { userId, medicationId: medication.id, scheduledFor: new Date(), }, }); return { medicationId: medication.id, intakeId: event.id }; } writeContract<{ medicationId: string; intakeId: string }>( "POST /api/medications/intake", { prepare: seedPendingDose, call: async ({ intakeId }) => { const { POST } = await import("@/app/api/medications/intake/route"); return post(POST as Handler, "/api/medications/intake", { intakeId, status: "taken", }); }, ok: 200, auditAction: "medications.intake.update", // The slot row is seeded, so what this counts is the marking: the number // of the account's intake events that now carry a `takenAt`. count: (userId) => getPrismaClient().medicationIntakeEvent.count({ where: { userId, takenAt: { not: null } }, }), }, ); /** * The two transitions the same route can express that are NOT marking a dose. * * `POST /api/medications/intake` is admitted because a caregiver standing next * to the patient has to be able to say the tablet went down. It is an update, * though, and the body can carry two other things. * * Snoozing writes `snoozedUntil` on the OWNER's medication row, and the * reminder cron skips a medication for as long as that stamp is in the future. * The field takes any future instant, the owner is deliberately not notified of * a delegated snooze, and the activity feed renders the whole family as * "marked a dose" — so before this was closed, a delegate could switch the * owner's medication reminders off for years and every surface built to make * delegation visible would have called it marking a dose. * * Flipping an already-resolved event is the second: taken to skipped rewrites * the owner's compliance history and refunds inventory, and the consent screen * promises that changing what is already there stays with the owner. * * Both assert the DATA, not only the status: a 403 that had already written * `snoozedUntil` would be worse than no refusal at all, because it would read * as protection. */ describe("POST /api/medications/intake — what a delegate may NOT do with it", () => { it("refuses to snooze the owner's reminders, and writes no stamp", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const { medicationId, intakeId } = await seedPendingDose(owner.id); await switchInto(owner.id, delegate.id, "WRITE"); const { POST } = await import("@/app/api/medications/intake/route"); const response = await post(POST as Handler, "/api/medications/intake", { intakeId, status: "snoozed", snoozedUntil: new Date(Date.now() + 5 * 365 * 86_400_000).toISOString(), }); expect(response.status).toBe(403); expect((await payload(response)).meta?.errorCode).toBe( "sharing.not_permitted", ); // The reminder cron reads this column and skips the medication while it is // in the future. It has to still be null. const med = await getPrismaClient().medication.findUniqueOrThrow({ where: { id: medicationId }, select: { snoozedUntil: true }, }); expect(med.snoozedUntil).toBeNull(); }); it("refuses to flip a dose the owner already recorded, and leaves it alone", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const { intakeId } = await seedPendingDose(owner.id); // The owner took it themselves. const takenAt = new Date(Date.now() - 3_600_000); await getPrismaClient().medicationIntakeEvent.update({ where: { id: intakeId }, data: { takenAt }, }); await switchInto(owner.id, delegate.id, "WRITE"); const { POST } = await import("@/app/api/medications/intake/route"); const response = await post(POST as Handler, "/api/medications/intake", { intakeId, status: "skipped", }); expect(response.status).toBe(403); const event = await getPrismaClient().medicationIntakeEvent.findUniqueOrThrow({ where: { id: intakeId }, select: { takenAt: true, skipped: true }, }); expect(event.skipped).toBe(false); expect(event.takenAt?.toISOString()).toBe(takenAt.toISOString()); }); it("still lets the owner snooze their own reminder", async () => { const owner = await makeUser("owner"); const { medicationId, intakeId } = await seedPendingDose(owner.id); await signIn(owner.id); const until = new Date(Date.now() + 1_800_000); const { POST } = await import("@/app/api/medications/intake/route"); const response = await post(POST as Handler, "/api/medications/intake", { intakeId, status: "snoozed", snoozedUntil: until.toISOString(), }); expect(response.status).toBe(200); const med = await getPrismaClient().medication.findUniqueOrThrow({ where: { id: medicationId }, select: { snoozedUntil: true }, }); expect(med.snoozedUntil?.toISOString()).toBe(until.toISOString()); }); }); /** * The way round the sibling route's refusal, closed. * * `POST /api/medications/intake` refuses a delegate changing a dose the owner * already recorded. This route reaches the same rows through the slot upsert, * so without the same rule it was simply the other door: an explicit skip * posted onto a taken slot flipped the outcome and refunded the inventory the * take had consumed. * * The rule compares OUTCOMES rather than refusing every write onto an actioned * slot, and the third case is why. A double tap, an offline replay and a retry * after a partial batch all re-post the SAME decision, and a caregiver hits all * three; refusing those would make the feature unreliable exactly when somebody * is standing in a kitchen with a pill box. * * Each case asserts the ROW, not only the status. A 403 that had already * written the flip would be worse than no refusal at all, because it would read * as protection. */ describe("POST /api/medications/[id]/intake — the slot upsert is not a way round", () => { /** * A medication with a real schedule window, and one dose in it the owner * already recorded. * * The window is the part that took three attempts to get right. A "taken" * write on this route does not honour the `scheduledFor` in the body: it * resolves the slot by BAND from `takenAt`, so an unscheduled medication has * no slot to converge onto, the write lands as a standalone ad-hoc row, and * the canonical slot upsert is never called at all. Every assertion about * what the upsert refuses then passes whatever the upsert does. The window is * anchored two hours back so the dose is in the past (a forward `takenAt` is * a 422 before any of this) and outside the route's 60 s dedup window. */ async function seedTakenSlot(ownerId: string) { const medication = await seedMedication(ownerId, "Insulin"); const slot = new Date(Date.now() - 2 * 3_600_000); slot.setMinutes(0, 0, 0); // The window is stated in the record owner's own zone, not in UTC. const localHour = new Intl.DateTimeFormat("en-GB", { timeZone: "Europe/Berlin", hour: "2-digit", hour12: false, }).format(slot); await getPrismaClient().medicationSchedule.create({ data: { medicationId: medication.id, windowStart: `${localHour}:00`, windowEnd: `${localHour}:45`, }, }); const event = await getPrismaClient().medicationIntakeEvent.create({ data: { userId: ownerId, medicationId: medication.id, scheduledFor: slot, takenAt: slot, }, }); return { medicationId: medication.id, slot, eventId: event.id }; } async function postIntake( medicationId: string, body: Record, ) { const { POST } = await import("@/app/api/medications/[id]/intake/route"); return post( POST as Handler, `/api/medications/${medicationId}/intake`, body, { id: medicationId }, ); } it("refuses a delegate flipping the owner's taken dose to skipped", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const { medicationId, slot, eventId } = await seedTakenSlot(owner.id); await switchInto(owner.id, delegate.id, "WRITE"); const response = await postIntake(medicationId, { skipped: true, scheduledFor: slot.toISOString(), }); expect(response.status).toBe(403); expect((await payload(response)).meta?.errorCode).toBe( "sharing.not_permitted", ); const row = await getPrismaClient().medicationIntakeEvent.findUniqueOrThrow( { where: { id: eventId }, select: { takenAt: true, skipped: true } }, ); expect(row.skipped).toBe(false); expect(row.takenAt).not.toBeNull(); }); it("still accepts a delegate re-posting the same decision", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const { medicationId, slot, eventId } = await seedTakenSlot(owner.id); await switchInto(owner.id, delegate.id, "WRITE"); const response = await postIntake(medicationId, { takenAt: new Date().toISOString(), scheduledFor: slot.toISOString(), }); expect([200, 201]).toContain(response.status); const row = await getPrismaClient().medicationIntakeEvent.findUniqueOrThrow( { where: { id: eventId }, select: { skipped: true, takenAt: true } }, ); expect(row.skipped).toBe(false); expect(row.takenAt).not.toBeNull(); }); it("refuses to move the time on a dose the owner already recorded", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const { medicationId, slot, eventId } = await seedTakenSlot(owner.id); await switchInto(owner.id, delegate.id, "WRITE"); // Inside the window, so the band binds this onto the slot the owner // already recorded rather than opening an ad-hoc row beside it. Five // minutes late is what a second tap looks like. const movedTo = new Date(slot.getTime() + 5 * 60_000); const replay = { takenAt: movedTo.toISOString(), doseTaken: "two tablets", idempotencyKey: "delegated-same-outcome-replay", }; const response = await postIntake(medicationId, replay); // Same decision, so this is not a refusal: it answers, and it answers with // the row that is already there. What it must never do is move the record. expect([200, 201]).toContain(response.status); const replayResponse = await postIntake(medicationId, replay); expect([200, 201]).toContain(replayResponse.status); const all = await getPrismaClient().medicationIntakeEvent.findMany({ where: { userId: owner.id }, select: { id: true, takenAt: true, scheduledFor: true, doseTaken: true }, }); // One row, not two: the write converged onto the owner's dose instead of // opening a second one beside it. Without this the case below would pass // on an ad-hoc row while the owner's record sat untouched next to it. expect(all).toHaveLength(1); const row = await getPrismaClient().medicationIntakeEvent.findUniqueOrThrow( { where: { id: eventId }, select: { takenAt: true, scheduledFor: true, doseTaken: true, skipped: true, }, }, ); expect(row.takenAt?.toISOString()).toBe(slot.toISOString()); expect(row.scheduledFor.toISOString()).toBe(slot.toISOString()); expect(row.doseTaken).toBeNull(); expect(row.skipped).toBe(false); }); it("still lets the owner correct their own dose", async () => { const owner = await makeUser("owner"); const { medicationId, slot, eventId } = await seedTakenSlot(owner.id); await signIn(owner.id); const response = await postIntake(medicationId, { skipped: true, scheduledFor: slot.toISOString(), }); expect([200, 201]).toContain(response.status); const row = await getPrismaClient().medicationIntakeEvent.findUniqueOrThrow( { where: { id: eventId }, select: { skipped: true } }, ); expect(row.skipped).toBe(true); }); }); writeContract<{ medicationId: string; intakeId: string }>( "POST /api/medications/[id]/intake", { prepare: async (recordOwnerId) => ({ medicationId: (await seedMedication(recordOwnerId, "Insulin")).id, intakeId: "", }), call: async ({ medicationId }) => { const { POST } = await import("@/app/api/medications/[id]/intake/route"); return post( POST as Handler, `/api/medications/${medicationId}/intake`, { takenAt: new Date().toISOString() }, { id: medicationId }, ); }, ok: 201, auditAction: "medication.intake", count: (userId) => getPrismaClient().medicationIntakeEvent.count({ where: { userId, takenAt: { not: null } }, }), }, ); describe("marking a dose — what the owner is told, and what the rollup recomputed", () => { it("hands the OWNER and the ACTOR to the notification, not one id twice", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const { medicationId, intakeId } = await seedPendingDose(owner.id); await switchInto(owner.id, delegate.id, "WRITE"); const { POST } = await import("@/app/api/medications/intake/route"); expect( ( await post(POST as Handler, "/api/medications/intake", { intakeId, status: "taken", }) ).status, ).toBe(200); expect(notifyDelegatedIntake).toHaveBeenCalledTimes(1); expect(notifyDelegatedIntake).toHaveBeenCalledWith({ ownerId: owner.id, actorId: delegate.id, medicationId, status: "taken", }); }); it("carries the skip through the per-medication arm", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const medication = await seedMedication(owner.id, "Insulin"); await switchInto(owner.id, delegate.id, "WRITE"); const { POST } = await import("@/app/api/medications/[id]/intake/route"); expect( ( await post( POST as Handler, `/api/medications/${medication.id}/intake`, { skipped: true, scheduledFor: new Date().toISOString() }, { id: medication.id }, ) ).status, ).toBe(201); expect(notifyDelegatedIntake).toHaveBeenCalledWith({ ownerId: owner.id, actorId: delegate.id, medicationId: medication.id, status: "skipped", }); }); it("still calls it with one id when nobody has switched", async () => { // The helper refuses on self, so this call is a no-op — but it has to be // MADE with `actorId === ownerId`, or the refusal is being reached for the // wrong reason. const plain = await makeUser("plain"); const { medicationId, intakeId } = await seedPendingDose(plain.id); await signIn(plain.id); const { POST } = await import("@/app/api/medications/intake/route"); expect( ( await post(POST as Handler, "/api/medications/intake", { intakeId, status: "taken", }) ).status, ).toBe(200); expect(notifyDelegatedIntake).toHaveBeenCalledWith({ ownerId: plain.id, actorId: plain.id, medicationId, status: "taken", }); }); it("recomputes the compliance rollup under the OWNER's id", async () => { // The dose landed in the owner's record; a rollup row keyed on the // delegate would leave the owner's compliance reading stale and give the // delegate a row about a medication they do not have. const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const { intakeId } = await seedPendingDose(owner.id); await switchInto(owner.id, delegate.id, "WRITE"); const { POST } = await import("@/app/api/medications/intake/route"); expect( ( await post(POST as Handler, "/api/medications/intake", { intakeId, status: "taken", }) ).status, ).toBe(200); const rollups = await getPrismaClient().medicationComplianceRollup.findMany( { select: { userId: true } }, ); expect(rollups.length).toBeGreaterThan(0); expect([...new Set(rollups.map((r) => r.userId))]).toEqual([owner.id]); }); });