fix(coach-prefs): return the pure default with no token for a never-saved user

The GET spread the optimistic-concurrency `updatedAt` into the response even
when the user had never saved preferences, which broke the strict-equality
contract that a never-saved read is exactly `DEFAULT_COACH_PREFS`. Mirror the
insights-layout never-saved GET: a null `coachPrefsJson` returns the pure
default with no token (the first save is the tokenless unconditional write), and
only a saved row and the PUT response carry `updatedAt`. Update the integration
round-trip to expect the token on the saved GET and PUT while keeping the
`coachPrefsJson` column assertion pure.
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 18:28:41 +02:00
1 parent e65390792d
commit 574367737f
3 files changed
+42 -14

No files matched your search

@@ -64,7 +64,24 @@ beforeEach(() => {
});
describe("GET /api/auth/me/coach-prefs", () => {
it("returns the prefs plus the optimistic-concurrency token", async () => {
it("returns a SAVED user's prefs plus the optimistic-concurrency token", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
coachPrefsJson: { tone: "concise" },
updatedAt: new Date("2026-07-24T10:00:00.000Z"),
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { tone: string; updatedAt?: string };
};
expect(env.data.tone).toBe("concise");
expect(env.data.updatedAt).toBe("2026-07-24T10:00:00.000Z");
});
it("returns the PURE default with NO token for a never-saved user", async () => {
// Mirrors the insights-layout never-saved GET: nothing to guard yet, and
// the first save is the tokenless unconditional write (backward-compat).
vi.mocked(prisma.user.findUnique).mockResolvedValue({
coachPrefsJson: null,
updatedAt: new Date("2026-07-24T10:00:00.000Z"),
@@ -76,7 +93,7 @@ describe("GET /api/auth/me/coach-prefs", () => {
data: { tone: string; updatedAt?: string };
};
expect(env.data.tone).toBe("warm");
expect(env.data.updatedAt).toBe("2026-07-24T10:00:00.000Z");
expect(env.data.updatedAt).toBeUndefined();
});
});
+10 -6
View File
@@ -39,12 +39,16 @@ export const GET = apiHandler(async () => {
where: { id: user.id },
select: { coachPrefsJson: true, updatedAt: true },
});
return apiSuccess({
...parseCoachPrefs(row?.coachPrefsJson),
// v1.32.22 (M4) — optimistic-concurrency token (additive sibling key; the
// prefs shape is a fixed key set, so `updatedAt` cannot collide).
updatedAt: row?.updatedAt?.toISOString(),
});
const prefs = parseCoachPrefs(row?.coachPrefsJson);
// v1.32.22 (M4) — a never-saved user gets the PURE default with NO token
// (mirrors the insights-layout never-saved GET). There is nothing to guard
// against yet, and the first save is just the tokenless unconditional write.
// A saved row carries `updatedAt` (additive sibling key; the prefs shape is a
// fixed key set, so it cannot collide) so the client can echo it.
if (row?.coachPrefsJson == null) {
return apiSuccess(prefs);
}
return apiSuccess({ ...prefs, updatedAt: row.updatedAt?.toISOString() });
});
export const PUT = apiHandler(async (req: Request) => {
+13 -6
View File
@@ -104,23 +104,30 @@ describe("PUT /api/auth/me/coach-prefs", () => {
}),
);
expect(putRes.status).toBe(200);
const putEnv = (await putRes.json()) as { data: typeof expected };
expect(putEnv.data).toEqual(expected);
// v1.32.22 (M4) — the write echoes the fresh optimistic-concurrency
// `updatedAt` token alongside the canonical prefs.
const putEnv = (await putRes.json()) as {
data: typeof expected & { updatedAt?: string };
};
expect(putEnv.data).toEqual({ ...expected, updatedAt: expect.any(String) });
// DB row reflects the canonical defaulted form.
// DB row reflects the canonical defaulted form. `coachPrefsJson` must NOT
// carry `updatedAt` — that token is the separate `User.updatedAt` column.
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { coachPrefsJson: true },
});
expect(row?.coachPrefsJson).toEqual(expected);
// GET reads the saved values back.
// GET reads the saved values back, now carrying the token (saved row).
const getRes = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/coach-prefs"),
);
expect(getRes.status).toBe(200);
const getEnv = (await getRes.json()) as { data: typeof expected };
expect(getEnv.data).toEqual(expected);
const getEnv = (await getRes.json()) as {
data: typeof expected & { updatedAt?: string };
};
expect(getEnv.data).toEqual({ ...expected, updatedAt: expect.any(String) });
});
it("rejects unknown tone values with 422", async () => {