mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The v1.4.22 B5 audit removed the placeholder cog from the Coach
drawer header because it had no real wiring. v1.4.23 H4 returns
the cog with an actual settings surface backed by a per-user
preferences row.
Schema: nullable JSONB column `users.coach_prefs_json` (migration
0038_coach_prefs). Null = legacy v1.4.22 defaults; non-null carries
`{ tone, verbosity, excludeMetrics, showEvidenceByDefault }` validated
by the Zod schema in `src/lib/validations/coach-prefs.ts`.
Routes: GET + PUT `/api/auth/me/coach-prefs`. PUT validates the body,
persists the canonical defaulted form, and emits an
`auth.me.coach-prefs.put` annotation with the four-knob meta. OpenAPI
registry covers both verbs; the spec regenerates without drift.
Coach prompt builder: `getCoachSystemPrompt(locale, prefs)` prepends a
per-user OVERRIDE paragraph for non-default tone (neutral / concise)
and verbosity (brief / detailed). Concise tone forces brief verbosity
regardless of the explicit selection — concise == short. Default
prefs leave the prompt unchanged so the v1.4.22 train stays in place.
Snapshot builder: reads the prefs row BEFORE any measurement query
and intersects `excludeMetrics` with the resolved scope. The model
never sees data the user opted out of — that's the load-bearing
order: filter first, query second.
UI: settings cog in the drawer header opens a right-edge `<Sheet>`
with three Select pickers (tone, verbosity) and one Switch column
(per-metric exclusions + showEvidenceByDefault). Save calls the PUT
route and invalidates the `["coach-prefs"]` query so the next reply
honours the new defaults without a reload. EN + DE i18n keys for
every label and hint.
Message-thread reads the same `["coach-prefs"]` cache and toggles the
`open` attribute on the evidence `<details>` element when
`showEvidenceByDefault` is true.
Tests +24 unit (coach-prefs schema, system-prompt prefix, snapshot
mock for new prefs row). Integration round-trip seeds a session,
PUTs a non-default prefs shape, asserts the GET reflects the saved
values + the DB row carries the canonical defaulted form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
4.3 KiB
TypeScript
131 lines
4.3 KiB
TypeScript
/**
|
|
* v1.4.23 H4 — per-user Coach prompt-tuning preferences route.
|
|
*
|
|
* Round-trips through GET → PUT → GET and asserts that:
|
|
* - the GET returns the documented defaults when the row is null
|
|
* - the PUT persists the supplied shape and returns the canonical
|
|
* defaulted form
|
|
* - subsequent GETs reflect the persisted values
|
|
* - malformed input is rejected with 422
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
|
|
|
|
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),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function seedSession(username: string) {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: { username, email: `${username}@example.test` },
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return user;
|
|
}
|
|
|
|
describe("GET /api/auth/me/coach-prefs", () => {
|
|
it("returns documented defaults when the user has never saved prefs", async () => {
|
|
await seedSession("coach-prefs-defaults");
|
|
|
|
const { GET } = await import("@/app/api/auth/me/coach-prefs/route");
|
|
const res = await (GET as () => Promise<Response>)();
|
|
expect(res.status).toBe(200);
|
|
const env = (await res.json()) as { data: typeof DEFAULT_COACH_PREFS };
|
|
expect(env.data).toEqual(DEFAULT_COACH_PREFS);
|
|
});
|
|
});
|
|
|
|
describe("PUT /api/auth/me/coach-prefs", () => {
|
|
it("persists the supplied shape and round-trips through GET", async () => {
|
|
const user = await seedSession("coach-prefs-roundtrip");
|
|
const prisma = getPrismaClient();
|
|
|
|
const { PUT, GET } = await import("@/app/api/auth/me/coach-prefs/route");
|
|
const body = {
|
|
tone: "concise" as const,
|
|
verbosity: "brief" as const,
|
|
excludeMetrics: ["mood" as const, "compliance" as const],
|
|
showEvidenceByDefault: true,
|
|
};
|
|
const putRes = await (PUT as (r: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/auth/me/coach-prefs", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
);
|
|
expect(putRes.status).toBe(200);
|
|
const putEnv = (await putRes.json()) as { data: typeof body };
|
|
expect(putEnv.data).toEqual(body);
|
|
|
|
// DB row reflects the canonical defaulted form.
|
|
const row = await prisma.user.findUnique({
|
|
where: { id: user.id },
|
|
select: { coachPrefsJson: true },
|
|
});
|
|
expect(row?.coachPrefsJson).toEqual(body);
|
|
|
|
// GET reads the saved values back.
|
|
const getRes = await (GET as () => Promise<Response>)();
|
|
expect(getRes.status).toBe(200);
|
|
const getEnv = (await getRes.json()) as { data: typeof body };
|
|
expect(getEnv.data).toEqual(body);
|
|
});
|
|
|
|
it("rejects unknown tone values with 422", async () => {
|
|
await seedSession("coach-prefs-invalid");
|
|
|
|
const { PUT } = await import("@/app/api/auth/me/coach-prefs/route");
|
|
const res = await (PUT as (r: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/auth/me/coach-prefs", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ tone: "stoic" }),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
});
|
|
});
|