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.
882 lines
33 KiB
TypeScript
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();
|
|
});
|
|
});
|