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

882 lines
33 KiB
TypeScript

/**
* The three needs-onboarding writes against real Postgres.
*
* The unit suite proves the pure parts — the area map, the merge that never
* retracts a hand-made decision, the apply function's idempotence. This file
* proves the four things a unit suite cannot, because each is about what is in
* the database afterwards:
*
* * **The answers persist and a replay changes nothing.** Idempotence is a
* claim on the wire, and a route can satisfy the pure function and still
* write a fresh row each time.
* * **The derivation happens once.** The second confirm has to leave the
* module map alone, including a module switched off in Settings between
* the two calls — that is the whole reason the latch exists.
* * **A restart resets the steps and not the modules.** The two are written
* to different tables by different routes, so only a real run can show
* that the second one stays where it was.
* * **A delegate cannot write another record's setup.** Proved with a live
* accepted grant and a switched session, not with a mocked resolver.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } 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);
},
})),
};
});
import { acceptGrant, inviteGrant } from "@/lib/sharing/grants";
import {
DEFAULT_DASHBOARD_LAYOUT,
resolveDashboardLayout,
serializeDashboardLayout,
} from "@/lib/dashboard-layout";
import { toJson } from "@/lib/db";
import { emptyOnboardingNeeds } from "@/lib/onboarding/needs";
import type {
OnboardingStateDto,
OnboardingStepId,
} from "@/lib/onboarding/needs";
let counter = 0;
async function makeUser(label: string) {
const suffix = `${label}-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `onb-${suffix}`,
email: `onb-${suffix}@example.test`,
role: "USER",
},
});
}
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;
}
/** Attach the record-session assertion the shipped browser client attaches. */
async function assertCurrentContext(sessionId: string) {
const row = await getPrismaClient().session.findUniqueOrThrow({
where: { id: sessionId },
});
headerJar.set("x-healthlog-record-epoch", String(row.recordEpoch));
headerJar.set("x-healthlog-record-scope", row.actingAsUserId ?? "self");
}
function jsonRequest(path: string, method: string, body: unknown): Request {
return new Request(`http://localhost${path}`, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function patchAnswer(body: unknown): Promise<Response> {
const { PATCH } = await import("@/app/api/onboarding/answers/route");
return (PATCH as (r: Request) => Promise<Response>)(
jsonRequest("/api/onboarding/answers", "PATCH", body),
);
}
async function postComplete(body: unknown = {}): Promise<Response> {
const { POST } = await import("@/app/api/onboarding/complete/route");
return (POST as (r: Request) => Promise<Response>)(
jsonRequest("/api/onboarding/complete", "POST", body),
);
}
async function postRestart(): Promise<Response> {
const { POST } = await import("@/app/api/onboarding/restart/route");
return (POST as (r: Request) => Promise<Response>)(
jsonRequest("/api/onboarding/restart", "POST", {}),
);
}
/** The `onboarding` field the account payload publishes for the caller. */
async function readMe(): Promise<OnboardingStateDto> {
const { GET } = await import("@/app/api/auth/me/route");
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me"),
);
const body = (await res.json()) as {
data: { onboarding: OnboardingStateDto };
};
return body.data.onboarding;
}
async function readState(res: Response): Promise<OnboardingStateDto> {
const body = (await res.json()) as {
data: { onboarding: OnboardingStateDto };
};
return body.data.onboarding;
}
function statusOf(state: OnboardingStateDto, id: OnboardingStepId) {
return state.steps.find((step) => step.id === id)?.status;
}
/**
* The answers a "blood pressure and medication" setup gives — all six
* questions, because the confirm screen is only reached once every one of them
* has an answer or a deliberate pass.
*/
async function answerTheQuestions() {
await patchAnswer({ step: "who", recordTarget: "me" });
await patchAnswer({ step: "areas", areas: ["blood-pressure", "labs"] });
await patchAnswer({ step: "medication", medication: "yes" });
await patchAnswer({ step: "sources", status: "skipped" });
await patchAnswer({ step: "visit", visit: "no" });
await patchAnswer({ step: "units", status: "skipped" });
}
async function modulePrefs(userId: string) {
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: userId },
select: { modulePreferencesJson: true },
});
return (row.modulePreferencesJson ?? {}) as Record<string, boolean>;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("PATCH /api/onboarding/answers", () => {
it("saves one step at a time and answers with the full nine", async () => {
const user = await makeUser("answers");
await signIn(user.id);
const first = await patchAnswer({ step: "who", recordTarget: "both" });
expect(first.status).toBe(200);
const state = await readState(first);
expect(state.needs.recordTarget).toBe("both");
expect(state.steps).toHaveLength(9);
expect(statusOf(state, "who")).toBe("done");
expect(statusOf(state, "areas")).toBe("pending");
const second = await readState(
await patchAnswer({ step: "areas", areas: ["sleep"] }),
);
expect(second.needs.recordTarget).toBe("both");
expect(second.needs.areas).toEqual(["sleep"]);
expect(statusOf(second, "areas")).toBe("done");
});
it("is idempotent — a replay leaves one row and the same state", async () => {
const user = await makeUser("replay");
await signIn(user.id);
const once = await readState(
await patchAnswer({ step: "medication", medication: "sometimes" }),
);
const twice = await readState(
await patchAnswer({ step: "medication", medication: "sometimes" }),
);
expect(twice).toEqual(once);
expect(
await getPrismaClient().onboardingRecord.count({
where: { userId: user.id },
}),
).toBe(1);
});
it("records a skip without erasing the answer already given", async () => {
const user = await makeUser("skip");
await signIn(user.id);
await patchAnswer({ step: "visit", visit: "within-a-month" });
const skipped = await readState(
await patchAnswer({ step: "visit", status: "skipped" }),
);
expect(statusOf(skipped, "visit")).toBe("skipped");
expect(skipped.needs.visit).toBe("within-a-month");
});
it("writes the record's real unit columns, and records the answer beside them", async () => {
const user = await makeUser("units");
await signIn(user.id);
const state = await readState(
await patchAnswer({
step: "units",
units: { glucoseUnit: "mmol/L", unitPreference: "imperial" },
}),
);
expect(state.needs.units).toEqual({
glucoseUnit: "mmol/L",
unitPreference: "imperial",
});
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: user.id },
select: { glucoseUnit: true, unitPreference: true },
});
expect(row).toEqual({ glucoseUnit: "mmol/L", unitPreference: "imperial" });
// Same trail as the two dedicated settings routes, so it does not matter
// which surface the person changed the units from.
const actions = (
await getPrismaClient().auditLog.findMany({
where: { userId: user.id },
select: { action: true },
})
).map((entry) => entry.action);
expect(actions).toContain("user.glucose-unit.update");
expect(actions).toContain("user.unit-preference.update");
});
it("does not re-ask the units the account already holds", async () => {
const user = await makeUser("units-held");
await signIn(user.id);
await getPrismaClient().user.update({
where: { id: user.id },
data: { glucoseUnit: "mg/dL", unitPreference: "metric" },
});
const state = await readState(
await patchAnswer({ step: "who", recordTarget: "me" }),
);
// Nobody answered Q6; the account's own columns did.
expect(statusOf(state, "units")).toBe("done");
expect(state.needs.units).toEqual({
glucoseUnit: null,
unitPreference: null,
});
// Half a preference is not an answer.
await getPrismaClient().user.update({
where: { id: user.id },
data: { unitPreference: null },
});
const half = await readState(
await patchAnswer({ step: "who", recordTarget: "me" }),
);
expect(statusOf(half, "units")).toBe("pending");
});
it("refuses a body that names a step it does not answer", async () => {
const user = await makeUser("invalid");
await signIn(user.id);
const res = await patchAnswer({ step: "areas", medication: "yes" });
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("onboarding.answers.invalid");
expect(
await getPrismaClient().onboardingRecord.count({
where: { userId: user.id },
}),
).toBe(0);
});
});
describe("POST /api/onboarding/complete", () => {
it("derives the module map from the answers, once", async () => {
const user = await makeUser("derive");
await signIn(user.id);
await answerTheQuestions();
const first = await postComplete();
expect(first.status).toBe(200);
const derived = await modulePrefs(user.id);
// What the answers named.
expect(derived.medications).toBe(true);
expect(derived.labs).toBe(true);
expect(derived.insights).toBe(true);
expect(derived.achievements).toBe(true);
// What they did not.
expect(derived.workouts).toBe(false);
expect(derived.mood).toBe(false);
expect(derived.doctorReport).toBe(false);
expect(derived.vaccinations).toBe(false);
// The delegated keys are never written into the blob.
expect(derived).not.toHaveProperty("cycle");
expect(derived).not.toHaveProperty("coach");
const state = await readState(first);
expect(statusOf(state, "confirm")).toBe("done");
expect(state.completedAt).not.toBeNull();
// The activity panel has to be able to answer "why did my modules change".
const audit = await getPrismaClient().auditLog.findMany({
where: { userId: user.id, action: "user.modules.update" },
});
expect(audit).toHaveLength(1);
const details = JSON.parse(audit[0].details ?? "{}") as {
changed?: string[];
source?: string;
};
expect(details.changed).toContain("medications");
expect(details.source).toBe("onboarding");
// Somebody changes their mind in Settings, then the confirm screen is
// replayed. The second call must leave that decision alone: the latch is
// what stops a questionnaire from re-applying itself over a later choice.
// The completion instant is latched with it — a replay does not move the
// moment the setup finished at.
await getPrismaClient().user.update({
where: { id: user.id },
data: { modulePreferencesJson: { ...derived, labs: false } },
});
const second = await postComplete();
expect(second.status).toBe(200);
expect((await modulePrefs(user.id)).labs).toBe(false);
expect((await readState(second)).completedAt).toBe(state.completedAt);
});
it("seeds the dashboard order from the answers, and only while the layout is unset", async () => {
const user = await makeUser("seed");
await signIn(user.id);
await answerTheQuestions();
expect((await postComplete()).status).toBe(200);
const stored = async () =>
(
await getPrismaClient().user.findUniqueOrThrow({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
})
).dashboardWidgetsJson;
// Blood pressure and a daily medication were answered: their tiles lead,
// visible on both surfaces, and nothing is dropped.
const seeded = await stored();
expect(seeded).not.toBeNull();
const layout = resolveDashboardLayout(seeded);
const order = [...layout.widgets]
.sort((a, b) => a.order - b.order)
.map((w) => w.id);
expect(new Set(order.slice(0, 4))).toEqual(
new Set(["bp", "bpInTarget", "pulse", "medications"]),
);
for (const id of ["bp", "bpInTarget", "pulse", "medications"]) {
const widget = layout.widgets.find((w) => w.id === id);
expect(widget?.visible, id).toBe(true);
expect(widget?.tileVisible, id).toBe(true);
}
expect(layout.widgets.length).toBe(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
// A layout somebody arranged is never clobbered: put the default back
// (weight first), ask the questions again, confirm again — the seed sees
// a set column and writes nothing.
await getPrismaClient().user.update({
where: { id: user.id },
data: {
dashboardWidgetsJson: toJson(
serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT),
),
},
});
expect((await postRestart()).status).toBe(200);
await answerTheQuestions();
expect((await postComplete()).status).toBe(200);
const kept = resolveDashboardLayout(await stored());
expect([...kept.widgets].sort((a, b) => a.order - b.order)[0]?.id).toBe(
"weight",
);
});
it("never switches off a module the person switched on by hand", async () => {
const user = await makeUser("handmade");
await signIn(user.id);
// The remote MCP endpoint is off by default and nothing in the flow asks
// for it, so a derivation that ignored the stored map would retract it.
await getPrismaClient().user.update({
where: { id: user.id },
data: { modulePreferencesJson: { mcp: true } },
});
await answerTheQuestions();
await postComplete();
expect((await modulePrefs(user.id)).mcp).toBe(true);
});
it("never switches off a domain the record already holds rows in", async () => {
const user = await makeUser("established");
await signIn(user.id);
// An account that has been running for years: mood entries and workouts,
// no stored module preference at all, because both were default-on and
// nobody ever touched a toggle. The questions below name neither.
await getPrismaClient().moodEntry.create({
data: {
userId: user.id,
date: "2026-09-01",
mood: "GUT",
score: 4,
moodLoggedAt: new Date("2026-09-01T18:00:00.000Z"),
},
});
await getPrismaClient().workout.create({
data: {
userId: user.id,
sportType: "RUNNING",
startedAt: new Date("2026-09-01T06:00:00.000Z"),
endedAt: new Date("2026-09-01T06:30:00.000Z"),
durationSec: 1800,
},
});
await answerTheQuestions();
await postComplete();
const prefs = await modulePrefs(user.id);
// Left alone rather than written false: absence is what "on" looks like
// in the stored map, and the surfaces stay.
expect(prefs).not.toHaveProperty("mood");
expect(prefs).not.toHaveProperty("workouts");
// A domain with nothing in it is still switched off by the answers.
expect(prefs.illness).toBe(false);
expect(prefs.vaccinations).toBe(false);
});
it("switches cycle tracking on through its own column when the area is chosen", async () => {
const user = await makeUser("cycle");
await signIn(user.id);
await patchAnswer({ step: "who", recordTarget: "me" });
await patchAnswer({ step: "areas", areas: ["cycle"] });
await patchAnswer({ step: "medication", status: "skipped" });
await patchAnswer({ step: "sources", status: "skipped" });
await patchAnswer({ step: "visit", status: "skipped" });
await patchAnswer({ step: "units", status: "skipped" });
await postComplete();
const profile = await getPrismaClient().cycleProfile.findUniqueOrThrow({
where: { userId: user.id },
});
expect(profile.cycleTrackingEnabled).toBe(true);
});
it("derives nothing from a questionnaire that was never finished", async () => {
const user = await makeUser("half");
await signIn(user.id);
// Q1 and nothing else — the tab was closed, or the legacy four-step wizard
// posted here afterwards. Either way the remaining five questions have no
// answer, and reading them as their conservative defaults would switch off
// eleven surfaces and latch the result.
await patchAnswer({ step: "who", recordTarget: "me" });
const res = await postComplete();
expect(res.status).toBe(200);
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: user.id },
select: { modulePreferencesJson: true },
});
expect(row.modulePreferencesJson).toBeNull();
const record = await getPrismaClient().onboardingRecord.findUniqueOrThrow({
where: { userId: user.id },
});
expect(record.modulesDerivedAt).toBeNull();
expect(record.completedAt).toBeNull();
const state = await readState(res);
expect(statusOf(state, "confirm")).toBe("pending");
// Finishing the questions later still derives, so the gate defers the
// work rather than losing it.
await answerTheQuestions();
await postComplete();
expect((await modulePrefs(user.id)).medications).toBe(true);
});
it("leaves a record that never answered the questions exactly as it was", async () => {
const user = await makeUser("legacy");
await signIn(user.id);
const res = await postComplete({ heightCm: 180 });
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { completed: boolean; onboarding?: unknown };
};
expect(body.data.completed).toBe(true);
expect(body.data.onboarding).toBeUndefined();
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: user.id },
select: { modulePreferencesJson: true, onboardingCompletedAt: true },
});
expect(row.modulePreferencesJson).toBeNull();
expect(row.onboardingCompletedAt).not.toBeNull();
});
});
describe("POST /api/onboarding/complete for somebody else's record", () => {
async function guardianWithChild() {
const guardian = await makeUser("guardian");
const { createManagedProfile } =
await import("@/lib/managed-profiles/create");
const { profile } = await createManagedProfile({
creatorId: guardian.id,
displayName: "Child record",
dateOfBirth: null,
locale: "en",
timezone: "UTC",
});
await signIn(guardian.id);
await patchAnswer({ step: "who", recordTarget: "someone-else" });
await patchAnswer({ step: "areas", areas: ["blood-pressure"] });
await patchAnswer({ step: "medication", medication: "no" });
await patchAnswer({ step: "sources", sources: ["manual"] });
await patchAnswer({ step: "visit", visit: "no" });
await patchAnswer({ step: "units", status: "skipped" });
return { guardian, profile };
}
it("derives onto the managed record and leaves the guardian's own map alone", async () => {
// The review's H1: a parent setting up a child's record must not have
// their own modules switched off around the child's answers. The
// derivation is the same function, applied through the same record-keyed
// write the guardian's modules route uses — to the child.
const { guardian, profile } = await guardianWithChild();
const res = await postComplete({ managedRecordId: profile.id });
expect(res.status).toBe(200);
const child = await modulePrefs(profile.id);
expect(child.vaccinations).toBe(true);
expect(child.insights).toBe(true);
expect(child.glucose).toBe(false);
expect(child.medications).toBe(false);
expect(child.doctorReport).toBe(false);
// Untouched: a fresh account carries no map at all, and still does.
expect(await modulePrefs(guardian.id)).toEqual({});
// The guardian's own flow is complete and its derivation latched, so a
// later confirm cannot derive the child's answers onto the guardian.
const own = await getPrismaClient().onboardingRecord.findUniqueOrThrow({
where: { userId: guardian.id },
});
expect(own.completedAt).not.toBeNull();
expect(own.modulesDerivedAt).not.toBeNull();
expect(statusOf(await readState(res), "confirm")).toBe("done");
// The child's own setup row reads as finished, from the same answers.
const theirs = await getPrismaClient().onboardingRecord.findUniqueOrThrow({
where: { userId: profile.id },
});
expect(theirs.completedAt).not.toBeNull();
expect(theirs.modulesDerivedAt).not.toBeNull();
const dashboard = await getPrismaClient().user.findUniqueOrThrow({
where: { id: profile.id },
select: { dashboardWidgetsJson: true },
});
expect(dashboard.dashboardWidgetsJson).not.toBeNull();
// A second confirm derives nowhere: the guardian is latched, and the
// child keeps what it has.
expect((await postComplete({ managedRecordId: profile.id })).status).toBe(
200,
);
expect(await modulePrefs(guardian.id)).toEqual({});
});
it("derives nowhere when the answers were for somebody else and no profile exists", async () => {
// H1' — "Finish without the profile" (no second factor, or a change of
// mind). There is no record the answers describe, and they must not
// describe the guardian's: the flow completes and latches, nothing is
// derived or seeded on the guardian, whatever the client sends.
const guardian = await makeUser("guardian-no-profile");
await signIn(guardian.id);
await patchAnswer({ step: "who", recordTarget: "someone-else" });
await patchAnswer({ step: "areas", areas: ["blood-pressure"] });
await patchAnswer({ step: "medication", medication: "yes" });
await patchAnswer({ step: "sources", sources: ["manual"] });
await patchAnswer({ step: "visit", visit: "within-a-month" });
await patchAnswer({ step: "units", status: "skipped" });
const res = await postComplete();
expect(res.status).toBe(200);
expect(await modulePrefs(guardian.id)).toEqual({});
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: guardian.id },
select: { dashboardWidgetsJson: true, onboardingCompletedAt: true },
});
expect(row.dashboardWidgetsJson).toBeNull();
expect(row.onboardingCompletedAt).not.toBeNull();
const own = await getPrismaClient().onboardingRecord.findUniqueOrThrow({
where: { userId: guardian.id },
});
expect(own.completedAt).not.toBeNull();
// Latched: a second confirm, with or without a record, derives nothing.
expect(own.modulesDerivedAt).not.toBeNull();
expect((await postComplete()).status).toBe(200);
expect(await modulePrefs(guardian.id)).toEqual({});
});
it("answers 404 for a record the caller does not guard, and touches nothing", async () => {
// A record addressed by id answers 404 for missing, not managed and
// not guarded alike, as `GET /api/managed-profiles/{id}` does through
// the same helper: one answer, no existence oracle. This asserted 403
// once; the id-addressed convention is 404.
const { guardian } = await guardianWithChild();
const stranger = await makeUser("stranger-guardian");
const { createManagedProfile } =
await import("@/lib/managed-profiles/create");
const { profile: theirs } = await createManagedProfile({
creatorId: stranger.id,
displayName: "Somebody else's child",
dateOfBirth: null,
locale: "en",
timezone: "UTC",
});
const res = await postComplete({ managedRecordId: theirs.id });
expect(res.status).toBe(404);
expect(
(await postComplete({ managedRecordId: "no-such-record" })).status,
).toBe(404);
expect(await modulePrefs(theirs.id)).toEqual({});
expect(await modulePrefs(guardian.id)).toEqual({});
const own = await getPrismaClient().onboardingRecord.findUniqueOrThrow({
where: { userId: guardian.id },
});
expect(own.completedAt).toBeNull();
// Nothing stamped the first-run gate either.
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: guardian.id },
select: { onboardingCompletedAt: true },
});
expect(row.onboardingCompletedAt).toBeNull();
});
it("refuses a managed record when the answers were given for the caller", async () => {
const guardian = await makeUser("guardian-me");
const { createManagedProfile } =
await import("@/lib/managed-profiles/create");
const { profile } = await createManagedProfile({
creatorId: guardian.id,
displayName: "Child record",
dateOfBirth: null,
locale: "en",
timezone: "UTC",
});
await signIn(guardian.id);
await answerTheQuestions(); // Q1 = "me"
const res = await postComplete({ managedRecordId: profile.id });
expect(res.status).toBe(422);
expect(await modulePrefs(profile.id)).toEqual({});
expect(await modulePrefs(guardian.id)).toEqual({});
});
});
describe("POST /api/onboarding/restart", () => {
it("clears the first result so a re-run offers the task again", async () => {
// M2: kept, the old result painted the re-run's first-result screen as
// done, "Next" skipped the write, and the step stayed pending forever.
const user = await makeUser("restart-first-result");
await signIn(user.id);
await answerTheQuestions();
expect((await postComplete()).status).toBe(200);
await patchAnswer({
step: "first-result",
firstResult: { task: "add-medication", target: null, completed: true },
});
expect((await readMe()).firstResult?.completedAt).not.toBeNull();
expect((await postRestart()).status).toBe(200);
const after = await readMe();
expect(after.firstResult).toBeNull();
expect(statusOf(after, "first-result")).toBe("pending");
});
it("resets the steps and leaves the modules where they are", async () => {
const user = await makeUser("restart");
await signIn(user.id);
await answerTheQuestions();
await postComplete();
const derived = await modulePrefs(user.id);
const res = await postRestart();
expect(res.status).toBe(200);
const state = await readState(res);
expect(state.steps.every((step) => step.status === "pending")).toBe(true);
expect(state.completedAt).toBeNull();
// The answers survive as the prefill for the re-run.
expect(state.needs.medication).toBe("yes");
expect(await modulePrefs(user.id)).toEqual(derived);
// The latch is cleared, so the next confirm may derive again — and a
// module switched off by hand in between still survives it.
await getPrismaClient().user.update({
where: { id: user.id },
data: { modulePreferencesJson: { ...derived, labs: true } },
});
await patchAnswer({ step: "who", recordTarget: "me" });
await patchAnswer({ step: "areas", areas: [] });
await patchAnswer({ step: "medication", medication: "no" });
await patchAnswer({ step: "sources", status: "skipped" });
await patchAnswer({ step: "visit", status: "skipped" });
await patchAnswer({ step: "units", status: "skipped" });
await postComplete();
// The derivation really did run the second time round, so the survival
// below is the merge protecting a hand-made decision and not the gate
// refusing to look.
const reran = await getPrismaClient().onboardingRecord.findUniqueOrThrow({
where: { userId: user.id },
});
expect(reran.modulesDerivedAt).not.toBeNull();
expect((await modulePrefs(user.id)).labs).toBe(true);
});
it("leaves the first-run redirect's own stamp alone", async () => {
const user = await makeUser("redirect");
await signIn(user.id);
await answerTheQuestions();
await postComplete();
await postRestart();
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: user.id },
select: { onboardingCompletedAt: true },
});
expect(row.onboardingCompletedAt).not.toBeNull();
});
});
describe("a delegate and somebody else's record", () => {
it("refuses every setup write while the session is acting on another record", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const invited = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
access: "READ",
scope: null,
});
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
const session = await signIn(delegate.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await assertCurrentContext(session.id);
for (const call of [
() => patchAnswer({ step: "who", recordTarget: "me" }),
() => postComplete(),
() => postRestart(),
]) {
const res = await call();
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("sharing.not_permitted");
}
// Neither record acquired a setup row — not the owner's, and not the
// delegate's own, which is the quiet failure a fall-back would produce.
expect(await getPrismaClient().onboardingRecord.count()).toBe(0);
const ownerRow = await getPrismaClient().user.findUniqueOrThrow({
where: { id: owner.id },
select: { modulePreferencesJson: true, onboardingCompletedAt: true },
});
expect(ownerRow.modulePreferencesJson).toBeNull();
expect(ownerRow.onboardingCompletedAt).toBeNull();
});
it("publishes the RECORD's setup state on the account payload, not the actor's", async () => {
const owner = await makeUser("scoped-owner");
const delegate = await makeUser("scoped-delegate");
// The owner sets up; the delegate never does.
await signIn(owner.id);
await answerTheQuestions();
await postComplete();
const invited = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
access: "READ",
scope: null,
});
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
cookieJar.clear();
headerJar.clear();
const session = await signIn(delegate.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await assertCurrentContext(session.id);
// The checklist reads this beside the record's reading count and
// medication count, both of which answer for the record under a switch.
// Answering it for the actor would order one record's rows by another
// person's answers.
const state = await readMe();
expect(state.needs.medication).toBe("yes");
expect(state.needs.areas).toEqual(["blood-pressure", "labs"]);
expect(state.completedAt).not.toBeNull();
expect(statusOf(state, "confirm")).toBe("done");
});
it("empties the answers under a grant that opens only one section", async () => {
const owner = await makeUser("section-owner");
const delegate = await makeUser("section-delegate");
await signIn(owner.id);
await answerTheQuestions();
await postComplete();
const invited = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
access: "READ",
scope: ["measurements"],
});
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
cookieJar.clear();
headerJar.clear();
const session = await signIn(delegate.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await assertCurrentContext(session.id);
// The answers name the domains the record tracks — the same thing the
// module map is masked for. The shape of the flow still comes through.
const state = await readMe();
expect(state.needs).toEqual(emptyOnboardingNeeds());
expect(state.completedAt).not.toBeNull();
});
});