mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
1337 lines
49 KiB
TypeScript
1337 lines
49 KiB
TypeScript
/**
|
|
* 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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
/**
|
|
* 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<any> },
|
|
) => Promise<Response>;
|
|
|
|
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<string, string> = {},
|
|
): Promise<Response> {
|
|
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<C> {
|
|
/** Rows the write depends on, created under the record it will land in. */
|
|
prepare?: (recordOwnerId: string) => Promise<C>;
|
|
/** Drive the shipped POST export against the current session. */
|
|
call: (ctx: C) => Promise<Response>;
|
|
/** 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<number>;
|
|
}
|
|
|
|
/**
|
|
* 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<C>(name: string, c: WriteCase<C>) {
|
|
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<C>(name: string, c: WriteCase<C>) {
|
|
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<C = undefined>(
|
|
name: string,
|
|
c: {
|
|
/** Seed whatever the write needs, owned by the RECORD. Mirrors `writeContract`. */
|
|
prepare?: (recordOwnerId: string) => Promise<C>;
|
|
call: (ctx: C) => Promise<Response>;
|
|
ok: number;
|
|
count: (userId: string) => Promise<number>;
|
|
},
|
|
) {
|
|
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<string>("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<string>("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<string, unknown>,
|
|
) {
|
|
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",
|
|
});
|
|
});
|
|
});
|