mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Wire the goals step to actually keep its promise. The picker now posts the chosen slugs on the step-2 submit; the step endpoint validates them against the closed slug enum (422 on an unknown slug), persists them to User.onboardingGoals field-by-field, and on completion seeds dashboardWidgetsJson from the selection — promoting the goal-mapped tiles to the top and forcing them visible. The seed is one-time, gated on a null dashboardWidgetsJson via the updateMany WHERE clause, so a user who already arranged tiles is never clobbered. An empty or general-wellness-only selection leaves the column untouched (default layout). The resulting layout is what both the web dashboard and the iOS widgets contract read — no client recompute. Skip posts an explicit empty array (deliberate no preference).
397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
/**
|
|
* v1.4.25 W21 Fix-M — concurrency regression for
|
|
* `POST /api/onboarding/step`.
|
|
*
|
|
* The step write was fetch-then-update: two parallel tabs both
|
|
* reading `onboardingStep = 2` and both POSTing `step: 3` would both
|
|
* succeed because the second write didn't re-check the precondition.
|
|
* The route now claims the row via `updateMany` with the precondition
|
|
* embedded in the WHERE clause, so exactly one update lands and the
|
|
* loser sees `count = 0` and returns 409.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
user: {
|
|
findUnique: vi.fn(),
|
|
findUniqueOrThrow: vi.fn(),
|
|
update: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
},
|
|
},
|
|
toJson: (v: unknown) => v,
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/session", () => ({
|
|
getSession: vi.fn(),
|
|
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/audit", () => ({
|
|
auditLog: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/rate-limit", () => ({
|
|
checkRateLimit: vi.fn().mockResolvedValue({
|
|
allowed: true,
|
|
remaining: 29,
|
|
resetAt: Date.now() + 60_000,
|
|
}),
|
|
rateLimitHeaders: vi.fn(() => ({})),
|
|
}));
|
|
|
|
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("next/headers", () => ({
|
|
headers: vi.fn(async () => ({ get: () => null })),
|
|
cookies: vi.fn(async () => ({
|
|
get: () => undefined,
|
|
set: () => {},
|
|
delete: () => {},
|
|
})),
|
|
}));
|
|
|
|
import { POST } from "../step/route";
|
|
import { prisma } from "@/lib/db";
|
|
import { getSession } from "@/lib/auth/session";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
|
|
const SESSION_OK = {
|
|
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
|
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
|
};
|
|
|
|
function req(body: unknown): NextRequest {
|
|
return new NextRequest("http://localhost/api/onboarding/step", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(checkRateLimit).mockResolvedValue({
|
|
allowed: true,
|
|
remaining: 29,
|
|
resetAt: Date.now() + 60_000,
|
|
});
|
|
});
|
|
|
|
describe("POST /api/onboarding/step — happy path", () => {
|
|
it("advances 0 → 1 on the first wizard submission", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
onboardingStep: 0,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
|
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
|
onboardingStep: 1,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
|
|
const res = await POST(req({ step: 1 }));
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { data: { step: number } };
|
|
expect(body.data.step).toBe(1);
|
|
});
|
|
|
|
it("marks completion on step 4 (sets onboardingCompletedAt)", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
onboardingStep: 3,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
|
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
|
onboardingStep: 4,
|
|
onboardingCompletedAt: new Date("2026-05-14T12:00:00Z"),
|
|
} as never);
|
|
|
|
const res = await POST(req({ step: 4 }));
|
|
expect(res.status).toBe(200);
|
|
const updateCall = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
|
|
expect(updateCall.data).toMatchObject({
|
|
onboardingStep: 4,
|
|
});
|
|
expect(updateCall.data).toHaveProperty("onboardingCompletedAt");
|
|
});
|
|
});
|
|
|
|
describe("POST /api/onboarding/step — guard rails", () => {
|
|
it("returns 401 when unauthenticated", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
const res = await POST(req({ step: 1 }));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("returns 422 for an out-of-range step value", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
const res = await POST(req({ step: 99 }));
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it("returns 409 when the user has already completed onboarding", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
onboardingStep: 4,
|
|
onboardingCompletedAt: new Date("2026-05-01T12:00:00Z"),
|
|
} as never);
|
|
|
|
const res = await POST(req({ step: 1 }));
|
|
expect(res.status).toBe(409);
|
|
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 409 when the requested step is out of order", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
onboardingStep: 1,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
|
|
// The route sees current = 1; submitting step 3 (skipping 2) must
|
|
// be rejected before the write.
|
|
const res = await POST(req({ step: 3 }));
|
|
expect(res.status).toBe(409);
|
|
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("POST /api/onboarding/step — concurrent write race", () => {
|
|
it("returns 409 when the conditional update affects zero rows", async () => {
|
|
// Regression for Fix-M / code-M7: two parallel tabs both read
|
|
// `onboardingStep = 2`, both POST `step: 3`. The first lands;
|
|
// when the second runs the conditional updateMany, the WHERE
|
|
// clause no longer matches (the column moved to 3) and Prisma
|
|
// returns `count = 0`. The route must surface 409 so the client
|
|
// can refresh and retry instead of double-advancing the wizard.
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
onboardingStep: 2,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
|
|
|
|
const res = await POST(req({ step: 3 }));
|
|
expect(res.status).toBe(409);
|
|
// findUniqueOrThrow must NOT be called when the claim fails —
|
|
// the route returns before re-reading the row.
|
|
expect(prisma.user.findUniqueOrThrow).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("includes the precondition in the updateMany WHERE clause", async () => {
|
|
// The whole point of the conditional update is that the WHERE
|
|
// clause re-asserts the precondition we read. Pin the contract
|
|
// so a future refactor can't quietly drop the guard.
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
onboardingStep: 2,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
|
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
|
onboardingStep: 3,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
|
|
await POST(req({ step: 3 }));
|
|
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
|
|
.where as Record<string, unknown>;
|
|
expect(whereArg).toMatchObject({
|
|
id: "user-1",
|
|
onboardingCompletedAt: null,
|
|
});
|
|
// Strict-equality precondition — Migration 0060 dropped the
|
|
// legacy nullable path so every advance uses `{ in: [current] }`.
|
|
expect(whereArg.onboardingStep).toEqual({ in: [2] });
|
|
});
|
|
|
|
it("advances a step-0 row to step 1 using the strict-equality precondition", async () => {
|
|
// Migration 0060 (v1.4.25 W21 Fix-O) backfilled NULL → 0 and
|
|
// flipped `onboarding_step` to NOT NULL, so the conditional
|
|
// update always uses the strict-equality form — there is no
|
|
// legacy null branch left for the route to widen.
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
onboardingStep: 0,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
|
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
|
onboardingStep: 1,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
|
|
const res = await POST(req({ step: 1 }));
|
|
expect(res.status).toBe(200);
|
|
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
|
|
.where as Record<string, unknown>;
|
|
expect(whereArg.onboardingStep).toEqual({ in: [0] });
|
|
});
|
|
});
|
|
|
|
describe("POST /api/onboarding/step — rate limit", () => {
|
|
it("returns 429 when the per-user rate limit is exceeded", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
|
allowed: false,
|
|
remaining: 0,
|
|
resetAt: Date.now() + 60_000,
|
|
});
|
|
const res = await POST(req({ step: 1 }));
|
|
expect(res.status).toBe(429);
|
|
});
|
|
});
|
|
|
|
describe("POST /api/onboarding/step — goal persistence (v1.17.1)", () => {
|
|
it("persists the selected goal slugs on the step-2 submit", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
onboardingStep: 1,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
|
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
|
onboardingStep: 2,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
|
|
const res = await POST(
|
|
req({ step: 2, goals: ["weight-management", "bp-tracking"] }),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
|
|
expect(data).toMatchObject({
|
|
onboardingStep: 2,
|
|
onboardingGoals: ["weight-management", "bp-tracking"],
|
|
});
|
|
});
|
|
|
|
it("persists an explicit empty array on skip (no preference)", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
onboardingStep: 1,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
|
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
|
onboardingStep: 2,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
|
|
await POST(req({ step: 2, goals: [] }));
|
|
const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
|
|
expect(data).toMatchObject({ onboardingGoals: [] });
|
|
});
|
|
|
|
it("rejects an unknown goal slug with 422", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
const res = await POST(req({ step: 2, goals: ["not-a-real-goal"] }));
|
|
expect(res.status).toBe(422);
|
|
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT write onboardingGoals when the field is omitted", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
onboardingStep: 1,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
|
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
|
onboardingStep: 2,
|
|
onboardingCompletedAt: null,
|
|
} as never);
|
|
|
|
await POST(req({ step: 2 }));
|
|
const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
|
|
expect(data).not.toHaveProperty("onboardingGoals");
|
|
});
|
|
});
|
|
|
|
describe("POST /api/onboarding/step — dashboard seeding on completion", () => {
|
|
it("seeds the dashboard from stored goals when dashboardWidgetsJson is null", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
// 1st findUnique = the fresh step-read; 2nd = the seed-read.
|
|
vi.mocked(prisma.user.findUnique)
|
|
.mockResolvedValueOnce({
|
|
onboardingStep: 3,
|
|
onboardingCompletedAt: null,
|
|
} as never)
|
|
.mockResolvedValueOnce({
|
|
onboardingGoals: ["glucose-tracking"],
|
|
dashboardWidgetsJson: null,
|
|
} as never);
|
|
// 1st updateMany = the step claim; 2nd = the gated seed write.
|
|
vi.mocked(prisma.user.updateMany)
|
|
.mockResolvedValueOnce({ count: 1 } as never)
|
|
.mockResolvedValueOnce({ count: 1 } as never);
|
|
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
|
onboardingStep: 4,
|
|
onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
|
|
} as never);
|
|
|
|
const res = await POST(req({ step: 4 }));
|
|
expect(res.status).toBe(200);
|
|
// The second updateMany is the seed; its WHERE pins the null gate
|
|
// and its data carries a dashboard layout.
|
|
expect(prisma.user.updateMany).toHaveBeenCalledTimes(2);
|
|
const seedCall = vi.mocked(prisma.user.updateMany).mock.calls[1][0];
|
|
expect(seedCall.data).toHaveProperty("dashboardWidgetsJson");
|
|
expect(seedCall.where).toHaveProperty("dashboardWidgetsJson");
|
|
});
|
|
|
|
it("does NOT seed when dashboardWidgetsJson is already set (gate holds)", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique)
|
|
.mockResolvedValueOnce({
|
|
onboardingStep: 3,
|
|
onboardingCompletedAt: null,
|
|
} as never)
|
|
.mockResolvedValueOnce({
|
|
onboardingGoals: ["glucose-tracking"],
|
|
dashboardWidgetsJson: { version: 1, widgets: [] },
|
|
} as never);
|
|
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
|
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
|
onboardingStep: 4,
|
|
onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
|
|
} as never);
|
|
|
|
const res = await POST(req({ step: 4 }));
|
|
expect(res.status).toBe(200);
|
|
// Only the step-claim updateMany fired — the seed was gated out
|
|
// because the user already has a layout.
|
|
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does NOT seed when stored goals are empty (no preference)", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique)
|
|
.mockResolvedValueOnce({
|
|
onboardingStep: 3,
|
|
onboardingCompletedAt: null,
|
|
} as never)
|
|
.mockResolvedValueOnce({
|
|
onboardingGoals: [],
|
|
dashboardWidgetsJson: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
|
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
|
onboardingStep: 4,
|
|
onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
|
|
} as never);
|
|
|
|
await POST(req({ step: 4 }));
|
|
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|