mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Merge the needs-based onboarding's server state and the derivation that never hides data
This commit is contained in:
commit
3017e24bb1
41 files changed
+5272
-83
No files matched your search
@@ -239,6 +239,8 @@ const COUNT_BACK: Record<
|
||||
p.medicationEfficacyTarget.count({ where: { medication: { userId } } }),
|
||||
MedicationScheduleRevision: (p, userId) =>
|
||||
p.medicationScheduleRevision.count({ where: { medication: { userId } } }),
|
||||
OnboardingRecord: (p, userId) =>
|
||||
p.onboardingRecord.count({ where: { userId } }),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -743,6 +745,35 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
lastComputedAt: AT("2026-07-01T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await prisma.onboardingRecord.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
needsJson: {
|
||||
recordTarget: "me",
|
||||
areas: ["blood-pressure", "sleep"],
|
||||
medication: "yes",
|
||||
sources: ["withings"],
|
||||
visit: "within-a-month",
|
||||
units: { glucoseUnit: "mmol/L", unitPreference: "metric" },
|
||||
},
|
||||
stepsJson: [
|
||||
{ id: "who", status: "done" },
|
||||
{ id: "areas", status: "done" },
|
||||
{ id: "sources", status: "skipped" },
|
||||
],
|
||||
firstResultJson: {
|
||||
task: "connect-source",
|
||||
target: "withings",
|
||||
completedAt: "2026-07-01T08:30:00.000Z",
|
||||
},
|
||||
// The once-only latch on the module derivation. Seeded non-null so the
|
||||
// assertion after the restore can show it came back rather than
|
||||
// defaulting — a null here would hand the next confirm permission to
|
||||
// re-apply the questionnaire over every module decision made since.
|
||||
modulesDerivedAt: AT("2026-07-01T08:00:00.000Z"),
|
||||
completedAt: AT("2026-07-01T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await prisma.healthScoreRecord.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
@@ -1413,6 +1444,55 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
{ pausedAt: "2026-07-15T08:00:00.000Z", resumedAt: null },
|
||||
]);
|
||||
|
||||
// The setup answers came back as answers, not as an empty questionnaire.
|
||||
//
|
||||
// Asserted column by column rather than by count: the row returning proves
|
||||
// nothing about `modulesDerivedAt`, and that field is the once-only latch
|
||||
// on the module derivation — a restore that let it default to null would
|
||||
// hand the next confirm permission to re-apply the questionnaire over
|
||||
// every module decision the account has taken since, silently.
|
||||
const restoredOnboarding = await prisma.onboardingRecord.findUniqueOrThrow({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
expect({
|
||||
needs: restoredOnboarding.needsJson,
|
||||
steps: restoredOnboarding.stepsJson,
|
||||
firstResult: restoredOnboarding.firstResultJson,
|
||||
modulesDerivedAt: restoredOnboarding.modulesDerivedAt?.toISOString(),
|
||||
completedAt: restoredOnboarding.completedAt?.toISOString(),
|
||||
}).toEqual({
|
||||
needs: {
|
||||
recordTarget: "me",
|
||||
areas: ["blood-pressure", "sleep"],
|
||||
medication: "yes",
|
||||
sources: ["withings"],
|
||||
visit: "within-a-month",
|
||||
units: { glucoseUnit: "mmol/L", unitPreference: "metric" },
|
||||
},
|
||||
// The stored blob is a status LOOKUP, and the reader answers with the
|
||||
// full ordered nine — a step the fixture never mentioned comes back
|
||||
// `pending` rather than missing, which is what lets the flow grow a
|
||||
// screen without a migration.
|
||||
steps: [
|
||||
{ id: "who", status: "done" },
|
||||
{ id: "areas", status: "done" },
|
||||
{ id: "medication", status: "pending" },
|
||||
{ id: "sources", status: "skipped" },
|
||||
{ id: "visit", status: "pending" },
|
||||
{ id: "units", status: "pending" },
|
||||
{ id: "confirm", status: "pending" },
|
||||
{ id: "first-result", status: "pending" },
|
||||
{ id: "done", status: "pending" },
|
||||
],
|
||||
firstResult: {
|
||||
task: "connect-source",
|
||||
target: "withings",
|
||||
completedAt: "2026-07-01T08:30:00.000Z",
|
||||
},
|
||||
modulesDerivedAt: "2026-07-01T08:00:00.000Z",
|
||||
completedAt: "2026-07-01T08:00:00.000Z",
|
||||
});
|
||||
|
||||
// The Coach came back able to speak, and the fence held.
|
||||
//
|
||||
// `documentScoped` is asserted per thread rather than in aggregate: a
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { acceptGrant, inviteGrant } from "@/lib/sharing/grants";
|
||||
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("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/restart", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user