test(integration): move the account preference route tests onto Postgres

Fourteen route tests for the /api/auth/me preference family replaced Prisma
with a mock and asserted the calls it received. Their claims are now shown
through the real handler on the stored row and the response: validation
refusals with their codes, normalisation and defaults on read, optimistic
lock conflicts against a real second writer, rate limits driven to their
first refusal in the Postgres limiter, audit rows read back, cache entries
evicted, and the jobs a change queues observed on a real pg-boss queue.

Assertions that were only about the shape of a Prisma call are replaced by
the behaviour they stood for, and two that described a state only a mock
can reach (a missing column) by the stored value that state resolves to.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 05:31:59 +02:00
1 parent 09f088e8a7
commit c0c0fdb421
27 files changed
+3525 -3893

No files matched your search

@@ -1,206 +0,0 @@
/**
* `PUT /api/auth/me/ai-usage` (#1217): the account's own level, within the
* operator's ceiling. A level above it is refused with 422
* `ai.usage.aboveCeiling` and `meta.maxLevel`, never capped silently; `null`
* returns to the instance default; the answer is the resolved `ai.usage`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
stored: null as string | null,
settings: {
aiUsageMaxLevel: "STANDARD",
aiUsageDefaultLevel: "STANDARD",
aiSyncRegenGapHours: null,
} as Record<string, unknown>,
usage: [] as Array<{ totalTokens: number }>,
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(async () => ({
aiUsageLevel: db.stored,
timezone: "UTC",
})),
update: vi.fn(async (args: { data: { aiUsageLevel: string | null } }) => {
db.stored = args.data.aiUsageLevel;
return {};
}),
},
appSettings: { findUnique: vi.fn(async () => db.settings) },
coachUsage: { findMany: vi.fn(async () => db.usage) },
},
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "UTC"),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
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 },
};
const put = PUT as unknown as (req: Request) => Promise<Response>;
function req(body: unknown): Request {
return new Request("http://localhost/api/auth/me/ai-usage", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
db.stored = null;
db.settings = {
aiUsageMaxLevel: "STANDARD",
aiUsageDefaultLevel: "STANDARD",
aiSyncRegenGapHours: null,
};
db.usage = [];
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 3_600_000,
} as never);
});
describe("PUT /api/auth/me/ai-usage", () => {
it("stores a level within the ceiling and answers the resolved state", async () => {
db.usage = [
{ totalTokens: 90_000 },
{ totalTokens: 110_000 },
{ totalTokens: 100_000 },
];
const res = await put(req({ level: "frugal" }));
expect(res.status).toBe(200);
expect(vi.mocked(prisma.user.update).mock.calls[0]![0]).toEqual({
where: { id: "user-1" },
data: { aiUsageLevel: "FRUGAL" },
});
const { data } = await res.json();
expect(data).toMatchObject({
level: "frugal",
preference: "frugal",
defaultLevel: "standard",
maxLevel: "standard",
source: "user",
measuredTokensPerDay: 100_000,
measuredDays: 3,
});
expect(data.options).toEqual([
{ level: "frugal", allowed: true, estimatedTokensPerDay: 100_000 },
{ level: "standard", allowed: true, estimatedTokensPerDay: 200_000 },
{ level: "full", allowed: false, estimatedTokensPerDay: 666_667 },
]);
expect(auditLog).toHaveBeenCalledWith(
"user.aiUsage.update",
expect.objectContaining({
userId: "user-1",
details: { previous: null, next: "frugal" },
}),
);
});
it("refuses a level above the ceiling with the ceiling named", async () => {
const res = await put(req({ level: "full" }));
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta).toMatchObject({
errorCode: "ai.usage.aboveCeiling",
maxLevel: "standard",
});
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("accepts full once the operator allows it", async () => {
db.settings = { ...db.settings, aiUsageMaxLevel: "FULL" };
const res = await put(req({ level: "full" }));
expect(res.status).toBe(200);
expect((await res.json()).data.level).toBe("full");
});
it("returns to the instance default on null", async () => {
db.stored = "FRUGAL";
const res = await put(req({ level: null }));
expect(res.status).toBe(200);
expect(vi.mocked(prisma.user.update).mock.calls[0]![0].data).toEqual({
aiUsageLevel: null,
});
const { data } = await res.json();
expect(data).toMatchObject({
level: "standard",
preference: null,
source: "default",
});
// Fewer than three days of history: no estimate.
expect(
data.options.every(
(o: { estimatedTokensPerDay: number | null }) =>
o.estimatedTokensPerDay === null,
),
).toBe(true);
});
it.each([
[{ level: "lavish" }],
[{}],
[{ level: "frugal", extra: true }],
[{ level: 1 }],
])("refuses %j as invalid", async (body) => {
const res = await put(req(body));
expect(res.status).toBe(422);
expect((await res.json()).meta.errorCode).toBe("ai.usage.invalid");
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses the 31st change in an hour", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const res = await put(req({ level: "frugal" }));
expect(res.status).toBe(429);
expect((await res.json()).meta.errorCode).toBe("ai.usage.rateLimited");
expect(vi.mocked(checkRateLimit).mock.calls[0]!.slice(1)).toEqual([
30,
60 * 60 * 1000,
]);
});
it("refuses a caller without a session", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await put(req({ level: "frugal" }));
expect(res.status).toBe(401);
});
});
@@ -1,221 +0,0 @@
/**
* v1.32.22 — `PUT /api/auth/me/coach-prefs` optimistic concurrency.
*
* A full-replace endpoint hydrated from a stale GET (two tabs / two devices)
* clobbers silently; the base token 409s that. The guarded write covers BOTH
* `coachPrefsJson` AND the mirrored `insightsExcludeMetrics` column in one
* conditional update, so a 409 leaves both untouched together. A tokenless PUT
* keeps the prior unconditional write.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
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 mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/coach-prefs", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("GET /api/auth/me/coach-prefs", () => {
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"),
} 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("warm");
expect(env.data.updatedAt).toBeUndefined();
});
});
describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => {
it("guards both columns in one conditional write and echoes the token", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({
excludeMetrics: ["weight"],
baseUpdatedAt: "2026-07-24T10:00:00.000Z",
}),
);
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0] as {
where: { id: string; updatedAt: Date };
data: { coachPrefsJson: unknown; insightsExcludeMetrics: string[] };
};
expect((call.where.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
// Both mirror columns ride the SAME guarded update.
expect(call.data.coachPrefsJson).toBeTruthy();
expect(call.data.insightsExcludeMetrics).toEqual(["weight"]);
expect(prisma.user.update).not.toHaveBeenCalled();
const env = (await res.json()) as { data: { updatedAt: string } };
expect(env.data.updatedAt).toBe("2026-07-24T10:05:00.000Z");
});
it("interleaved writers: a stale token 409s and leaves BOTH columns untouched", async () => {
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({
excludeMetrics: ["weight"],
baseUpdatedAt: "2026-07-24T08:00:00.000Z",
}),
);
expect(res.status).toBe(409);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("coach_prefs_conflict");
// Nothing written — the single guarded update matched no row.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the token is omitted (compat)", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ excludeMetrics: ["weight"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
data: { coachPrefsJson: unknown; insightsExcludeMetrics: string[] };
};
expect(call.data.insightsExcludeMetrics).toEqual(["weight"]);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ excludeMetrics: ["weight"], baseUpdatedAt: "not-a-date" }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
describe("PUT /api/auth/me/coach-prefs — thinking depth (v1.41)", () => {
it("stores the chosen level and names it on the wide event", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-10-06T11:00:00.000Z"),
} as never);
const { annotate } = await import("@/lib/logging/context");
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ reasoning: "high" }),
);
expect(res.status).toBe(200);
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
data: { coachPrefsJson: { reasoning?: string } };
};
expect(call.data.coachPrefsJson.reasoning).toBe("high");
const env = (await res.json()) as { data: { reasoning?: string } };
expect(env.data.reasoning).toBe("high");
const meta = vi
.mocked(annotate)
.mock.calls.map((c) => c[0])
.find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta;
expect(meta).toMatchObject({ reasoning: "high" });
});
it("reads an absent level as the default on the wide event", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-10-06T11:00:00.000Z"),
} as never);
const { annotate } = await import("@/lib/logging/context");
await (PUT as (r: Request) => Promise<Response>)(mkPut({ tone: "warm" }));
const meta = vi
.mocked(annotate)
.mock.calls.map((c) => c[0])
.find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta;
expect(meta).toMatchObject({ reasoning: "medium" });
});
it.each(["xhigh", "none", 2, null])("422s %s as a level", async (value) => {
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ reasoning: value }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -1,207 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The declared diabetes flag is the one preference that switches every
* glucose surface from the general normal bands to the ADA glycemic goal
* bands and reaches the Coach snapshot. The web toggle in the thresholds
* section rides this route, so the round trip has to hold: a PATCH writes
* the column and echoes the resolved state, and a GET reads it back.
*/
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
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 mkGet(): Request {
return new Request("http://localhost/api/auth/me/diabetes");
}
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/diabetes", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/diabetes", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns false for a user who never declared the flag", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
hasDiabetes: false,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { hasDiabetes: boolean } };
expect(env.data.hasDiabetes).toBe(false);
});
it("returns true after the flag was declared", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
hasDiabetes: true,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { hasDiabetes: boolean } };
expect(env.data.hasDiabetes).toBe(true);
});
});
describe("PATCH /api/auth/me/diabetes", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ hasDiabetes: true }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("writes the flag on, echoes it back, and audits the transition", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
hasDiabetes: false,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ hasDiabetes: true }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { hasDiabetes: boolean } };
expect(env.data.hasDiabetes).toBe(true);
// The write must actually land, scoped to the caller, field-by-field.
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { hasDiabetes: true },
});
expect(auditLog).toHaveBeenCalledWith(
"user.diabetes.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({ previous: false, next: true }),
}),
);
});
it("writes the flag off and echoes the resolved state", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
hasDiabetes: true,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ hasDiabetes: false }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { hasDiabetes: boolean } };
expect(env.data.hasDiabetes).toBe(false);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { hasDiabetes: false },
});
});
it("rejects a missing hasDiabetes field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ otherField: true }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a non-boolean hasDiabetes field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ hasDiabetes: "true" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("returns 429 when the per-user rate-limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ hasDiabetes: true }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -1,270 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
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 mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/disable-coach", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/disable-coach", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/disable-coach"),
);
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns false for a fresh user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
disableCoach: false,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/disable-coach"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { disableCoach: boolean } };
expect(env.data.disableCoach).toBe(false);
});
it("returns true for an opted-out user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
disableCoach: true,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/disable-coach"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { disableCoach: boolean } };
expect(env.data.disableCoach).toBe(true);
});
it("returns false when the column is missing (partial-deploy rollback)", async () => {
// Defensive default — the API surface tolerates a `findUnique`
// result that doesn't contain the column. Belt-and-braces with the
// schema-level NOT NULL default; both layers keep the Coach
// visible by default for users who never opted out.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/disable-coach"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { disableCoach: boolean } };
expect(env.data.disableCoach).toBe(false);
});
});
describe("PATCH /api/auth/me/disable-coach", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ disableCoach: true }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("flips the column on and writes the enable audit row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
disableCoach: false,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ disableCoach: true }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { disableCoach: boolean } };
expect(env.data.disableCoach).toBe(true);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { disableCoach: true },
});
expect(auditLog).toHaveBeenCalledWith(
"user.disable-coach.enable",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: false,
next: true,
}),
}),
);
});
it("flips the column off and writes the disable audit row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
disableCoach: true,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ disableCoach: false }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { disableCoach: boolean } };
expect(env.data.disableCoach).toBe(false);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { disableCoach: false },
});
expect(auditLog).toHaveBeenCalledWith(
"user.disable-coach.disable",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: true,
next: false,
}),
}),
);
});
it("rejects a missing disableCoach field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ otherField: "ignored" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a non-boolean disableCoach field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ disableCoach: "true" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// 400, not the 422 this used to pin. A body that will not parse is a
// client-side serialisation fault, and `safeJson` plus roughly two hundred
// and twenty other routes have always answered 400 for it; the dotted token
// moved to `meta.errorCode`, which is where a machine code belongs.
it("rejects malformed JSON with 400", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/disable-coach", {
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({
error: "Invalid JSON body",
meta: { errorCode: "disable-coach.body.invalid_json" },
});
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("is idempotent when the column already holds the requested value", async () => {
// Toggling a column from true→true (or false→false) still writes
// through so the audit row exists. Matches the doctor-report-prefs
// route's "always write" posture.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
disableCoach: true,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ disableCoach: true }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(auditLog).toHaveBeenCalledTimes(1);
});
it("returns 429 when the per-user rate-limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ disableCoach: true }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -1,120 +0,0 @@
/**
* The strict half, end to end on a real route.
*
* `patchBodySchema` is `.strict()`, so a PATCH carrying a key the route
* does not declare is refused rather than trimmed down to the keys it
* does. Before that, `{ disableCoachh: true }` parsed clean as `{}` —
* except `disableCoach` is required, so this particular body already
* 422'd on the missing field. The one worth pinning is the body that
* used to succeed while doing something other than what it said:
* `{ disableCoach: true, disableCoachh: false }`.
*
* The second assertion is the user-visible half. A 422 that says only
* "Validation failed" leaves the caller staring at a body it believes is
* correct, so the envelope has to name the key it refused — and name it
* without reflecting a hostile one back verbatim.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
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 mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/disable-coach", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
const patch = PATCH as (r: Request) => Promise<Response>;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.update).mockResolvedValue({
disableCoach: true,
} as never);
});
describe("PATCH /api/auth/me/disable-coach — unknown keys", () => {
it("accepts the declared body", async () => {
const res = await patch(mkPatch({ disableCoach: true }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
it("refuses a body that carries an undeclared key alongside a valid one", async () => {
const res = await patch(
mkPatch({ disableCoach: true, disableCoachh: false }),
);
expect(res.status).toBe(422);
// Nothing was written. Before `.strict()` this wrote `disableCoach:
// true` and answered 200, so a caller that meant the second field
// was told its request had been honoured.
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("names the offending key in the 422", async () => {
const res = await patch(
mkPatch({ disableCoach: true, disableCoachh: false }),
);
const body = (await res.json()) as {
error: string;
details?: {
issues: Array<{ code: string; message: string; keys?: string[] }>;
};
};
const issue = body.details?.issues.find(
(i) => i.code === "unrecognized_keys",
);
expect(issue).toBeDefined();
expect(issue?.keys).toEqual(["disableCoachh"]);
expect(issue?.message).toContain("disableCoachh");
});
it("does not reflect a hostile key name back verbatim", async () => {
const res = await patch(
mkPatch({ disableCoach: true, "<script>alert(1)</script>": 1 }),
);
expect(res.status).toBe(422);
const raw = await res.text();
expect(raw).not.toContain("<script>");
expect(raw).toContain("script_alert_1");
});
});
@@ -1,197 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The auto-read opt-in endpoint, and specifically the catch-up it schedules.
*
* The summary job is enqueued at UPLOAD time and no-ops while the flag is OFF,
* so without a catch-up on the flip the toggle only ever applied to future
* uploads — a user who filled the vault first saw the switch do nothing. These
* tests pin that a genuine OFF→ON transition schedules the pass, and that a
* no-op re-save or a flip to OFF does not.
*/
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
consentReceipt: { findFirst: vi.fn(), create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
getAiCapability: vi.fn(),
}));
vi.mock("@/lib/jobs/document-summary-catchup", () => ({
enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
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 mkPatch(documentsAutoAiRead: boolean): Request {
return new Request("http://localhost/api/auth/me/documents-auto-ai-read", {
method: "PATCH",
body: JSON.stringify({ documentsAutoAiRead }),
headers: { "Content-Type": "application/json" },
});
}
/** Seed the flag's value BEFORE the PATCH under test. */
function withPrevious(documentsAutoAiRead: boolean) {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentsAutoAiRead,
} as never);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
vi.mocked(prisma.consentReceipt.create).mockResolvedValue({} as never);
vi.mocked(getAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
});
describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () => {
it("schedules a catch-up when the opt-in flips OFF→ON", async () => {
withPrevious(false);
const res = await PATCH(mkPatch(true));
expect(res.status).toBe(200);
expect(enqueueSummaryCatchUp).toHaveBeenCalledWith("user-1");
expect(enqueueSummaryCatchUp).toHaveBeenCalledTimes(1);
});
it("does not re-schedule when the opt-in was already ON", async () => {
// Idempotency at the trigger: re-saving an already-ON setting must not
// queue a second pass over the same documents.
withPrevious(true);
await PATCH(mkPatch(true));
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
});
it("does not schedule anything when the opt-in is turned OFF", async () => {
withPrevious(true);
await PATCH(mkPatch(false));
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
});
it("mints the extraction receipt on the flip that schedules the pass", async () => {
// The catch-up rides the same act of consent, never around it. The toggle
// grants document reading only: `ai_extraction`, not the master `ai_full`
// that would also open the Coach and the AI analysis.
withPrevious(false);
await PATCH(mkPatch(true));
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
const data = vi.mocked(prisma.consentReceipt.create).mock.calls[0]![0]
.data as { userId: string; kind: string };
expect(data.userId).toBe("user-1");
expect(data.kind).toBe("ai_extraction");
// The capability is read after the mint, so the fresh receipt counts.
const mintOrder = vi.mocked(prisma.consentReceipt.create).mock
.invocationCallOrder[0]!;
const readOrder = vi.mocked(getAiCapability).mock.invocationCallOrder[0]!;
expect(mintOrder).toBeLessThan(readOrder);
});
it("does not mint a second receipt when one that covers document reads is active", async () => {
withPrevious(false);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
id: "r1",
} as never);
await PATCH(mkPatch(true));
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
revokedAt: null,
kind: { in: ["ai_extraction", "ai_full"] },
}),
}),
);
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
});
it("treats a concurrent mint as success", async () => {
withPrevious(false);
vi.mocked(prisma.consentReceipt.create).mockRejectedValue({
code: "P2002",
});
const res = await PATCH(mkPatch(true));
expect(res.status).toBe(200);
});
it("saves the preference but schedules no catch-up while document reading is closed", async () => {
// The operator turned document reading off: every summary the pass would
// enqueue could only be refused, so it is not enqueued at all.
withPrevious(false);
vi.mocked(getAiCapability).mockResolvedValue({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
});
const res = await PATCH(mkPatch(true));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalled();
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
});
it("persists the flag field-by-field", async () => {
withPrevious(false);
await PATCH(mkPatch(true));
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { documentsAutoAiRead: true },
});
});
});
@@ -1,247 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The blood-glucose display unit — the writer the column never had.
*
* `User.glucoseUnit` shipped in v1.2 and about thirty surfaces read it, but
* no route, form or schema could ever set it, so every account stayed on the
* mg/dL default and the mmol/L half of the app was dead code. This pins the
* missing end: a PATCH writes the column and echoes the resolved state, a GET
* reads it back, and an unrecognised unit is refused rather than stored —
* a stray string in that column would resolve to mg/dL and quietly put every
* mmol/L reader back on the wrong number.
*/
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/insights/unit-change-refresh", () => ({
refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
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 mkGet(): Request {
return new Request("http://localhost/api/auth/me/glucose-unit");
}
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/glucose-unit", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/glucose-unit", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("resolves an account that never chose to mg/dL", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { glucoseUnit: string } };
expect(env.data.glucoseUnit).toBe("mg/dL");
});
it("reads back the unit the account chose", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
const env = (await res.json()) as { data: { glucoseUnit: string } };
expect(env.data.glucoseUnit).toBe("mmol/L");
});
});
describe("PATCH /api/auth/me/glucose-unit", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("moves an account to mmol/L, echoes it, and audits the transition", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { glucoseUnit: string } };
expect(env.data.glucoseUnit).toBe("mmol/L");
// The write must actually land, scoped to the caller, field-by-field.
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { glucoseUnit: "mmol/L" },
});
expect(auditLog).toHaveBeenCalledWith(
"user.glucose-unit.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: "mg/dL",
next: "mmol/L",
}),
}),
);
});
it("moves an account back to mg/dL", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mg/dL" }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { glucoseUnit: "mg/dL" },
});
});
it("refuses a unit outside the two the app renders", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
for (const bad of ["mmol/l", "mgdl", "imperial", "", 1]) {
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: bad }),
);
expect(res.status).toBe(422);
}
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a missing glucoseUnit field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ otherField: "mmol/L" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("returns 429 when the per-user rate-limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("PATCH re-warms the texts that quote the unit", () => {
it("re-warms the stored notes when the unit actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1");
});
it("leaves them alone when the same unit is saved again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled();
});
});
@@ -1,572 +0,0 @@
/**
* v1.35.0 — GET/PATCH /api/auth/me/health-score-config.
*
* Covers the read projection, the positive-selection-in /
* deselection-out inversion, the write-time breadth refusal, the
* monotonic recipe version, and the score-cache eviction. The
* invalidation assertion is here because a missing one is invisible:
* the write succeeds, the response is right, and the person keeps
* seeing the old number for an hour.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/cache/invalidate")>();
return { ...actual, invalidateUserHealthScore: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import type { ScorePillarId } from "@/lib/analytics/score/types";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
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 },
};
const ALL_PILLARS: ScorePillarId[] = [
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"LIPIDS",
];
interface ResolvedBody {
pillars: ScorePillarId[];
excludedPillars: ScorePillarId[];
hasSelection: boolean;
version: number;
changedAt: string | null;
updatedAt?: string;
}
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/health-score-config", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
/** Mutable row, so a write is visible to the read that follows it. */
function primeUser(healthScoreConfigJson: unknown = null) {
const row: { healthScoreConfigJson: unknown; updatedAt: Date } = {
healthScoreConfigJson,
updatedAt: new Date("2026-07-31T09:00:00.000Z"),
};
vi.mocked(prisma.user.findUnique).mockImplementation((() =>
Promise.resolve({ ...row })) as never);
vi.mocked(prisma.user.update).mockImplementation(((args: {
data?: { healthScoreConfigJson?: unknown };
}) => {
if (args.data && "healthScoreConfigJson" in args.data) {
row.healthScoreConfigJson = args.data.healthScoreConfigJson;
}
return Promise.resolve({ updatedAt: row.updatedAt });
}) as never);
return row;
}
function writtenConfig(): {
excludedPillars: ScorePillarId[];
version: number;
changedAt: string;
} {
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
data: {
healthScoreConfigJson: {
excludedPillars: ScorePillarId[];
version: number;
changedAt: string;
};
};
};
return call.data.healthScoreConfigJson;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/health-score-config", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/health-score-config"),
);
expect(res.status).toBe(401);
});
it("reports every pillar counting, and no selection, for a fresh account", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/health-score-config"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: ResolvedBody };
expect(env.data.pillars).toEqual(ALL_PILLARS);
expect(env.data.hasSelection).toBe(false);
expect(env.data.version).toBe(0);
expect(env.data.updatedAt).toBeUndefined();
});
it("reports a stored selection as the pillars it leaves counting", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({
excludedPillars: ["SLEEP", "LIPIDS"],
version: 2,
changedAt: "2026-07-30T08:00:00.000Z",
});
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/health-score-config"),
);
const env = (await res.json()) as { data: ResolvedBody };
expect(env.data.pillars).toEqual(
ALL_PILLARS.filter((id) => id !== "SLEEP" && id !== "LIPIDS"),
);
expect(env.data.excludedPillars).toEqual(["SLEEP", "LIPIDS"]);
expect(env.data.hasSelection).toBe(true);
expect(env.data.version).toBe(2);
expect(env.data.updatedAt).toBe("2026-07-31T09:00:00.000Z");
});
});
describe("PATCH /api/auth/me/health-score-config", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("stores the complement of the selection and returns the selection", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const selection: ScorePillarId[] = [
"BLOOD_PRESSURE",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
];
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: selection }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: ResolvedBody };
expect(env.data.pillars).toEqual(selection);
expect(env.data.hasSelection).toBe(true);
const stored = writtenConfig();
expect(stored.excludedPillars).toEqual(
ALL_PILLARS.filter((id) => !selection.includes(id)),
);
expect(stored.version).toBe(1);
expect(stored.changedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(auditLog).toHaveBeenCalledWith(
"user.health_score_config.update",
expect.objectContaining({ userId: "user-1" }),
);
});
it("increments the recipe version on every accepted write", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({
excludedPillars: ["LIPIDS"],
version: 7,
changedAt: "2026-07-01T00:00:00.000Z",
});
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS }),
);
expect(writtenConfig().version).toBe(8);
});
it("keeps a kept-everything selection as an authored choice", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS }),
);
const env = (await res.json()) as { data: ResolvedBody };
expect(writtenConfig().excludedPillars).toEqual([]);
expect(env.data.hasSelection).toBe(true);
});
it("evicts the score caches so the old number is not served on", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS }),
);
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
});
});
/**
* v1.38 — what the write still refuses, after the breadth floor became a
* recommendation.
*
* Two of the three refusals this block used to pin are gone. The route
* turned away `["ACTIVITY", "WELLBEING"]` with
* `measured_physiological_domain_required` and the cardiometabolic
* triple with `three_domains_required`, both on the ground that the
* selection could not produce a score. It can now — the scorer computes
* from whatever is readable and labels how broad the set was — and a
* write that kept refusing what the read is willing to serve would be
* the two layers disagreeing, which is the drift the shared rule exists
* to prevent. So those two assertions are inverted to acceptances.
*
* The empty selection stays refused, and that case is why this is a
* change of rule rather than a removal of one.
*/
describe("PATCH — the breadth rule", () => {
it("accepts activity and wellbeing alone, and stores them", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["ACTIVITY", "WELLBEING"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
});
it("accepts the cardiometabolic triple, which is three pillars in one area", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
it("accepts a single pillar", async () => {
// The narrowest selection that can still be scored, and the boundary
// the one surviving refusal sits against.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["ACTIVITY"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
it("refuses an empty selection rather than storing a score that cannot exist", async () => {
// The counter-case. A write that accepted everything would have
// stopped enforcing anything, and this is the one selection the
// scorer genuinely cannot answer.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: [] }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as {
error: string;
meta?: { errorCode?: string; reason?: string };
};
expect(env.meta?.errorCode).toBe("health_score_config.too_narrow");
expect(env.meta?.reason).toBe("no_pillars_selected");
expect(env.error).toContain("at least one area of health");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
});
it("accepts a three-area selection", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
});
describe("PATCH — envelope and transport", () => {
it("rejects an unknown pillar id with 422 and writes nothing", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["BLOOD_PRESSURE", "MOON_PHASE", "SLEEP"] }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an unknown body key with 422 (strict stays strict)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS, weights: { SLEEP: 2 } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses a userId in the body", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS, userId: "user-2" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects malformed JSON with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request(
"http://localhost/api/auth/me/health-score-config",
{
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
},
);
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("returns 429 when the per-user rate limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("guards the write on the base token and 409s a stale one", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({
pillars: ALL_PILLARS,
baseUpdatedAt: "2026-07-24T08:00:00.000Z",
}),
);
expect(res.status).toBe(409);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("health_score_config_conflict");
expect(prisma.user.update).not.toHaveBeenCalled();
// A write that never landed must not evict the caches either.
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
});
});
/**
* v1.35.1 — the route shipped in v1.35.0 with a settings surface in front
* of it and no published contract at all: `grep -c health-score-config
* docs/api/openapi.yaml` returned 0. This block is the second end.
*
* It does not compare the spec against a restatement of the spec. It drives
* the REAL route to produce each response, reads the codes and keys off the
* bytes it actually returns, and asserts the published contract names them.
* A response the route can emit but the contract does not describe fails
* here, which is the failure the release itself did not catch.
*/
describe("the published contract matches what the route serves", () => {
const PATH = "/api/auth/me/health-score-config";
function doc() {
return buildOpenApiDocument() as {
paths?: Record<
string,
Record<string, { responses?: Record<string, { description?: string }> }>
>;
components?: {
schemas?: Record<
string,
{ properties?: Record<string, unknown>; required?: string[] }
>;
};
};
}
/** Every `meta.errorCode` the PATCH route can actually produce. */
async function emittedErrorCodes(): Promise<Map<number, Set<string>>> {
const byStatus = new Map<number, Set<string>>();
const record = async (body: unknown, conflict = false) => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
vi.mocked(prisma.user.updateMany).mockResolvedValue({
count: conflict ? 0 : 1,
} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch(body),
);
const env = (await res.json()) as { meta?: { errorCode?: string } };
if (env.meta?.errorCode) {
const set = byStatus.get(res.status) ?? new Set<string>();
set.add(env.meta.errorCode);
byStatus.set(res.status, set);
}
};
// Too narrow. One reason since v1.38, because one selection is: the
// empty one.
await record({ pillars: [] });
// Body that fails validation.
await record({ pillars: ["NOT_A_PILLAR"] });
// Present-but-unparseable base token.
await record({ pillars: ALL_PILLARS, baseUpdatedAt: null });
// Stale base token.
await record(
{ pillars: ALL_PILLARS, baseUpdatedAt: "2026-07-24T08:00:00.000Z" },
true,
);
return byStatus;
}
it("registers both verbs", () => {
const path = doc().paths?.[PATH];
expect(
path,
`${PATH} is not registered in the OpenAPI document`,
).toBeDefined();
expect(Object.keys(path ?? {}).sort()).toEqual(["get", "patch"]);
});
it("names every errorCode the route can emit, on the right status", async () => {
const emitted = await emittedErrorCodes();
const responses = doc().paths?.[PATH]?.patch?.responses ?? {};
// Sanity: the driver really did produce the codes, so a contract that
// named none of them could not pass by the loop never running.
expect([...(emitted.get(422) ?? [])].sort()).toEqual([
"health_score_config.invalid",
"health_score_config.too_narrow",
"invalid_base_updated_at",
]);
expect([...(emitted.get(409) ?? [])]).toEqual([
"health_score_config_conflict",
]);
for (const [status, codes] of emitted) {
const described = responses[String(status)]?.description ?? "";
for (const code of codes) {
expect(
described,
`${status} description does not mention \`${code}\``,
).toContain(code);
}
}
});
it("publishes exactly the fields a real response carries", async () => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS }),
);
const env = (await res.json()) as { data: ResolvedBody };
expect(res.status).toBe(200);
const schema = doc().components?.schemas?.["HealthScoreConfigResponse"];
expect(schema, "HealthScoreConfigResponse is not emitted").toBeDefined();
expect(Object.keys(schema?.properties ?? {}).sort()).toEqual(
Object.keys(env.data).sort(),
);
// `updatedAt` is absent on a never-configured GET, so it must not be
// required — a generated decoder would reject that read.
expect(schema?.required ?? []).not.toContain("updatedAt");
});
it("leaves updatedAt off the read that genuinely has none", async () => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request(`http://localhost${PATH}`),
);
const env = (await res.json()) as { data: ResolvedBody };
const published = Object.keys(
doc().components?.schemas?.["HealthScoreConfigResponse"]?.properties ??
{},
);
expect(Object.keys(env.data)).not.toContain("updatedAt");
for (const key of Object.keys(env.data)) expect(published).toContain(key);
});
});
@@ -1,463 +0,0 @@
/**
* v1.18.0 — PATCH/GET /api/auth/me/modules.
*
* Covers the read projection, the field-by-field merge, core-module
* refusal (strict schema), the cycle/coach delegation reflected in the
* resolved map, and the standard auth / rate-limit / 422 envelope.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
cycleProfile: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
// Operator master flag = on by default; coach delegation keys off it.
// Partial mock: the rest of the module stays real, and both switch readers
// answer from the one the test drives.
vi.mock("@/lib/feature-flags", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/feature-flags")>()),
...(
await import("@/__tests__/helpers/assistant-switches-mock")
).mockAssistantSwitches(vi.fn().mockResolvedValue({ coach: true })),
}));
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/cache/invalidate")>();
return { ...actual, invalidateUserHealthScore: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAssistantFlags } from "@/lib/feature-flags";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
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 },
};
type ModuleMap = Record<string, boolean>;
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/modules", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
/**
* Default DB shape: fresh user, no module prefs, no cycle row. The
* `findUnique` mock reads from a mutable holder so a PATCH `update`
* (which writes `modulePreferencesJson`) is reflected by the post-update
* `resolveModuleMap` re-read — mirroring real DB round-trip semantics.
*/
function primeUser(
over: {
gender?: string | null;
disableCoach?: boolean;
modulePreferencesJson?: unknown;
cycleTrackingEnabled?: boolean | null;
} = {},
) {
const row: {
gender: string | null;
disableCoach: boolean;
modulePreferencesJson: unknown;
} = {
gender: over.gender ?? null,
disableCoach: over.disableCoach ?? false,
modulePreferencesJson: over.modulePreferencesJson ?? null,
};
vi.mocked(prisma.user.findUnique).mockImplementation((() =>
Promise.resolve({ ...row })) as never);
vi.mocked(prisma.user.update).mockImplementation(((args: {
data?: { modulePreferencesJson?: unknown };
}) => {
const data = args.data;
if (data && "modulePreferencesJson" in data) {
row.modulePreferencesJson = data.modulePreferencesJson;
}
return Promise.resolve({});
}) as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(
over.cycleTrackingEnabled === undefined
? null
: ({ cycleTrackingEnabled: over.cycleTrackingEnabled } as never),
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never);
});
describe("GET /api/auth/me/modules", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/modules"),
);
expect(res.status).toBe(401);
});
it("returns every toggleable module on for a fresh user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser();
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/modules"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { modules: ModuleMap } };
expect(env.data.modules.mood).toBe(true);
expect(env.data.modules.sleep).toBe(true);
expect(env.data.modules.insights).toBe(true);
// cycle gender-derived (null gender ⇒ off); coach delegated (on).
expect(env.data.modules.cycle).toBe(false);
expect(env.data.modules.coach).toBe(true);
});
it("reflects a disabled module from the persisted allowlist", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: { glucose: false } });
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/modules"),
);
const env = (await res.json()) as { data: { modules: ModuleMap } };
expect(env.data.modules.glucose).toBe(false);
expect(env.data.modules.mood).toBe(true);
});
it("reflects coach delegation (operator master flag off)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(getAssistantFlags).mockResolvedValue({ coach: false } as never);
primeUser();
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/modules"),
);
const env = (await res.json()) as { data: { modules: ModuleMap } };
expect(env.data.modules.coach).toBe(false);
});
});
describe("PATCH /api/auth/me/modules", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("disables a module and persists the merged allowlist", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { modules: ModuleMap } };
expect(env.data.modules.mood).toBe(false);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { mood: false } },
});
expect(auditLog).toHaveBeenCalledWith(
"user.modules.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({ changed: ["mood"] }),
}),
);
});
it("merges over the existing allowlist field-by-field (siblings intact)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// user previously disabled glucose; now disabling sleep too.
primeUser({ modulePreferencesJson: { glucose: false } });
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ sleep: false }),
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { glucose: false, sleep: false } },
});
});
it("can re-enable a previously disabled module", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: { glucose: false } });
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucose: true }),
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { glucose: true } },
});
});
it("REFUSES to disable a core module (strict schema 422)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser();
// v1.18.1 — medications graduated to a toggleable module, so only
// weight / blood pressure / pulse stay refused as core domains.
for (const core of ["weight", "bloodPressure", "pulse"]) {
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ [core]: false }),
);
expect(res.status).toBe(422);
}
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an unknown module key with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser();
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ notAModule: false }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a non-boolean value with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser();
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: "false" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// 400, not the 422 this used to pin. A body that will not parse is a
// client-side serialisation fault, and `safeJson` plus roughly two hundred
// and twenty other routes have always answered 400 for it; the dotted token
// moved to `meta.errorCode`, which is where a machine code belongs.
it("rejects malformed JSON with 400", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/modules", {
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({
error: "Invalid JSON body",
meta: { errorCode: "modules.body.invalid_json" },
});
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("returns 429 when the per-user rate-limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("drops a previously-corrupted stored blob to clean booleans on merge", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// junk in the stored row must not leak into the persisted merge.
primeUser({ modulePreferencesJson: { mood: "yes", sleep: false } });
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ labs: false }),
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { sleep: false, labs: false } },
});
});
});
/**
* v1.35.0 — four of the score's pillars come and go with a module
* toggle, so a toggle changes what the composite is made of. The score
* caches hold the old composition and are served for up to an hour, so
* the person who just turned a module off keeps seeing a number
* computed from it. Nothing about that is visible in a test that does
* not assert the eviction, which is why it went unnoticed for so long.
*/
describe("PATCH /api/auth/me/modules — the score caches", () => {
it("evicts the score caches after a module toggle lands", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ sleep: false }),
);
expect(res.status).toBe(200);
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
});
it("does not evict them when the write never landed", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ sleep: false, baseUpdatedAt: "2026-07-24T08:00:00.000Z" }),
);
expect(res.status).toBe(409);
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
});
});
/**
* v1.32.22 — optimistic concurrency. The base token is stripped BEFORE
* the `.strict()` schema parse, so it never trips strict validation; a stale
* token 409s and writes nothing; a tokenless PATCH keeps the unconditional
* write.
*/
describe("PATCH /api/auth/me/modules — optimistic concurrency", () => {
it("guards on the base token and skips the unconditional write", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("interleaved writers: the stale-base second writer gets 409 and clobbers nothing", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T08:00:00.000Z" }),
);
expect(res.status).toBe(409);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("modules_conflict");
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the token is omitted (compat)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("strips the token pre-parse: a token ALONE does not trip the strict schema", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
// 200, not 422 — `baseUpdatedAt` was removed before the strict parse.
expect(res.status).toBe(200);
});
it("still 422s an unknown key even alongside a valid token (strict stays strict)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ notAModule: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "not-a-date" }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
@@ -1,754 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
toJson: <T>(v: T) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
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 mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/notification-prefs", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/notification-prefs", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns the documented defaults for a fresh user (null row)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
it("returns the resolved prefs when the row holds a value", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
it("returns defaults when the persisted shape has drifted", async () => {
// Forward-compat: an admin hand-edit / future-rename should not
// crash the GET.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { unknownCategory: { foo: "bar" } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
});
describe("PATCH /api/auth/me/notification-prefs", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("flips medication.clientManaged on and writes the audit row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
// v1.32.22 — the write echoes the fresh optimistic-concurrency token.
updatedAt: expect.any(String),
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
},
});
expect(auditLog).toHaveBeenCalledWith(
"user.notification-prefs.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: {
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
next: {
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
changed: ["medication"],
}),
}),
);
});
// 400, not the 422 this used to pin. A body that will not parse is a
// client-side serialisation fault, and `safeJson` plus roughly two hundred
// and twenty other routes have always answered 400 for it; the dotted token
// moved to `meta.errorCode`, which is where a machine code belongs.
it("rejects malformed JSON with 400", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/notification-prefs", {
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({
error: "Invalid JSON body",
meta: { errorCode: "notification-prefs.body.invalid_json" },
});
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a non-boolean clientManaged with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: "yes" } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("deep-merges over existing siblings without overwriting them", async () => {
// Forward-compat: a row that already carries an unknown sibling
// category (e.g. a future "mood" key persisted by a newer client)
// must survive a PATCH that only touches `medication`. The route
// parses with the current zod schema first, then deep-merges the
// input over the parsed (defaulted) base. Today, "unknown sibling"
// resolves back to defaults; the test pins that the medication
// PATCH still lands and that future schema growth (adding new
// siblings to the zod shape) will preserve them.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: false } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
},
});
});
it("returns the merged shape unchanged on an empty PATCH body", async () => {
// Idempotent — an empty body keeps the row as-is and still writes
// the audit trail (mirrors the disable-coach posture).
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(mkPatch({}));
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
// v1.32.22 — the write echoes the fresh optimistic-concurrency token.
updatedAt: expect.any(String),
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
},
});
});
it("v1.7.0 — persists a custom mood.reminderHour", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: { reminderHour: 9 } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { mood: { reminderHour: number } };
};
expect(env.data.mood).toEqual({ reminderHour: 9 });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 9 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
},
});
});
it("persists content.detail and keeps the siblings", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { mood: { reminderHour: 7 } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ content: { detail: "detailed" } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { content: { detail: string }; mood: { reminderHour: number } };
};
expect(env.data.content).toEqual({ detail: "detailed" });
expect(env.data.mood).toEqual({ reminderHour: 7 });
const written = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
data: { notificationPrefs: { content: { detail: string } } };
};
expect(written.data.notificationPrefs.content).toEqual({
detail: "detailed",
});
});
it("rejects an unknown content.detail with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ content: { detail: "everything" } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("v1.7.0 — rejects a mood.reminderHour outside 0..23 with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: { reminderHour: 24 } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("v1.16.11 — round-trips a custom lowStockRunwayDays", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { lowStockRunwayDays: 14 } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { lowStockRunwayDays: number | null } };
};
expect(env.data.medication.lowStockRunwayDays).toBe(14);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: expect.objectContaining({
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 14,
reorderLeadDays: 10,
},
}),
},
});
});
it("v1.16.11 — persists the explicit null (alert off)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { lowStockRunwayDays: 14 } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { lowStockRunwayDays: null } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { lowStockRunwayDays: number | null } };
};
expect(env.data.medication.lowStockRunwayDays).toBe(null);
});
it("v1.16.11 — rejects a lowStockRunwayDays outside 1..60 with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
for (const value of [0, 61]) {
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { lowStockRunwayDays: value } }),
);
expect(res.status).toBe(422);
}
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("returns 429 when the per-user rate-limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
/**
* v1.32.22 — optimistic concurrency (issue #581 family). A PATCH carrying
* the `baseUpdatedAt` it was based on writes CONDITIONALLY on the stored row
* still carrying that token. The headline case: a concurrent web PATCH of a
* DIFFERENT category can no longer silently revert the iOS `clientManaged`
* flag (the double-reminder class). A tokenless PATCH keeps the prior
* unconditional write — the iOS-compat arm.
*/
describe("PATCH /api/auth/me/notification-prefs — optimistic concurrency", () => {
function mkTokenedPatch(body: unknown, baseUpdatedAt?: string): Request {
return mkPatch(
baseUpdatedAt !== undefined
? { ...(body as object), baseUpdatedAt }
: body,
);
}
it("guards on the base token and echoes the advanced token", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique)
// merge read
.mockResolvedValueOnce({ notificationPrefs: null } as never)
// post-write fresh-token read
.mockResolvedValueOnce({
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch(
{ medication: { clientManaged: true } },
"2026-07-24T10:00:00.000Z",
),
);
expect(res.status).toBe(200);
// Conditional write, no unconditional fallback.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
expect(prisma.user.update).not.toHaveBeenCalled();
const env = (await res.json()) as { data: { updatedAt: string } };
expect(env.data.updatedAt).toBe("2026-07-24T10:05:00.000Z");
});
it("regression: a stale-token web write does NOT revert the iOS clientManaged flag", async () => {
// iOS set medication.clientManaged = true (row now carries it). A web card
// PATCH based on a pre-iOS read of a DIFFERENT category arrives with a stale
// token: it must 409 and write NOTHING, so clientManaged survives.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch({ mood: { reminderHour: 9 } }, "2026-07-24T08:00:00.000Z"),
);
expect(res.status).toBe(409);
const env = (await res.json()) as {
data: null;
meta?: { errorCode?: string };
};
expect(env.data).toBeNull();
expect(env.meta?.errorCode).toBe("notification_prefs_conflict");
// The guarded write matched no row; nothing was written, so the persisted
// clientManaged flag is untouched.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("compat: the SAME second write TOKENLESS still lands (old clients keep last-write-wins)", async () => {
// Deliberate: a tokenless client keeps the pre-guard race. This pins that
// the clobber for old clients is a documented choice, not an accident.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: { reminderHour: 9 } }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch({ medication: { clientManaged: true } }, "not-a-date"),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -1,153 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/integrations/sources-in-use", () => ({
getSourcesInUse: vi.fn().mockResolvedValue(["WITHINGS", "MANUAL"]),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
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 { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
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 mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/source-priority", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/source-priority", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(401);
});
it("returns the resolved shape with defaults when the column is null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as { data: Record<string, unknown> };
// Resolver always emits the keys the analytics layer needs.
expect(env.data).toHaveProperty("weight");
expect(env.data).toHaveProperty("metricPriority");
expect(env.data).toHaveProperty("deviceTypePriority");
});
it("carries the account's sources in use beside the ladders", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
const res = await (GET as () => Promise<Response>)();
const env = (await res.json()) as { data: { inUse: string[] } };
expect(env.data.inUse).toEqual(["WITHINGS", "MANUAL"]);
});
});
describe("PUT /api/auth/me/source-priority", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("persists the partial shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = { weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] };
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { sourcePriorityJson: body },
});
});
it("rejects an invalid shape with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ weight: "not-an-array" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── v1.4.25 W10 reconcile (security M-3) ──
it("writes an audit-log entry with the previous and new shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const previousShape = {
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: previousShape,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = { weight: ["APPLE_HEALTH", "WITHINGS", "MANUAL"] };
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"user.source-priority.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: previousShape,
next: body,
}),
}),
);
});
});
@@ -1,146 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
appSettings: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// The supplement nutrient rows are keyed by the same local day.
const nutrientMock = vi.hoisted(() => vi.fn(async () => ({ queued: true })));
vi.mock("@/lib/medications/ingredients/recompute-job", () => ({
enqueueSupplementNutrientRecompute: nutrientMock,
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
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 mkReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/auth/me/timezone", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("PUT /api/auth/me/timezone", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
expect(res.status).toBe(401);
});
it("accepts a valid IANA zone and writes it to the user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
timezone: "Pacific/Auckland",
} as never);
const res = await PUT(mkReq({ timezone: "Pacific/Auckland" }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { timezone: "Pacific/Auckland" },
});
});
it("rejects an invalid timezone with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({ timezone: "Mars/Olympus" }));
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an empty timezone with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({ timezone: "" }));
expect(res.status).toBe(422);
});
it("rejects a missing timezone field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({}));
expect(res.status).toBe(422);
});
// The supplement nutrient rows' day keys were minted under the old zone;
// without the enqueue nothing re-buckets them.
it("enqueues the nutrient recompute when the zone actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await PUT(mkReq({ timezone: "America/New_York" }));
expect(res.status).toBe(200);
expect(nutrientMock).toHaveBeenCalledWith("user-1");
});
it("does not recompute when the zone is unchanged", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
expect(res.status).toBe(200);
expect(nutrientMock).not.toHaveBeenCalled();
});
it("trims whitespace before validating", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
timezone: "Asia/Tokyo",
} as never);
const res = await PUT(mkReq({ timezone: " Asia/Tokyo " }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { timezone: "Asia/Tokyo" },
});
});
});
@@ -1,234 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/insights/unit-change-refresh", () => ({
refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
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 mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/unit-preference", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/unit-preference", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/unit-preference"),
);
expect(res.status).toBe(401);
});
it("defaults to metric for a fresh user (null column)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/unit-preference"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { unitPreference: string };
};
expect(env.data.unitPreference).toBe("metric");
});
it("defaults to metric when the column is missing (partial rollback)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/unit-preference"),
);
const env = (await res.json()) as { data: { unitPreference: string } };
expect(env.data.unitPreference).toBe("metric");
});
it("returns imperial when set", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: "imperial",
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/unit-preference"),
);
const env = (await res.json()) as { data: { unitPreference: string } };
expect(env.data.unitPreference).toBe("imperial");
});
});
describe("PATCH /api/auth/me/unit-preference", () => {
it("sets imperial and writes the audit row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "imperial" }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { unitPreference: string } };
expect(env.data.unitPreference).toBe("imperial");
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { unitPreference: "imperial" },
});
expect(auditLog).toHaveBeenCalledWith(
"user.unit-preference.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: "metric",
next: "imperial",
}),
}),
);
});
it("rejects an unknown preference value with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "furlongs" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// The three cases below pin the capped `safeJson` read. Before it, the
// handler called `request.json()` bare: malformed JSON came back 422 rather
// than the 400 every sibling scalar answers, a wrong content type was parsed
// rather than refused, and there was NO body-size limit — this was the one
// endpoint in the family that would materialise whatever it was handed. The
// malformed-JSON assertion used to say 422 and was changed deliberately: it
// pinned the inconsistency rather than a contract, and the only caller
// branches on `res.ok` alone.
it("rejects malformed JSON with 400, like its sibling scalars", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/unit-preference", {
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(400);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses a body over the 1 KB cap with 413", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Valid JSON, valid enum value, and far too big: the cap has to be what
// rejects it, not the schema.
const req = new Request("http://localhost/api/auth/me/unit-preference", {
method: "PATCH",
body: JSON.stringify({
unitPreference: "imperial",
padding: "x".repeat(2048),
}),
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(413);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses a non-JSON content type with 415", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/unit-preference", {
method: "PATCH",
body: "unitPreference=imperial",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(415);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("PATCH re-warms the texts that quote the unit", () => {
it("re-warms the stored notes when the unit actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "imperial" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1");
});
it("leaves them alone when the same unit is saved again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: "imperial",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "imperial" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled();
});
});
@@ -1,103 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The workout-route opt-in: off by default, a hard-set PATCH, audit-logged,
* and a malformed body refused before anything is written.
*/
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
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 mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/workout-routes", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
});
describe("/api/auth/me/workout-routes", () => {
it("reads off when the row carries no value", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
data: { storeWorkoutRoutes: false },
});
});
it("sets the flag field by field and audits the change", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
storeWorkoutRoutes: false,
} as never);
const res = await PATCH(mkPatch({ storeWorkoutRoutes: true }));
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
data: { storeWorkoutRoutes: true },
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { storeWorkoutRoutes: true },
});
expect(auditLog).toHaveBeenCalledWith(
"user.storeWorkoutRoutes.update",
expect.objectContaining({
details: { previous: false, next: true },
}),
);
});
it("refuses a malformed body with 422 and writes nothing", async () => {
const res = await PATCH(mkPatch({ storeWorkoutRoutes: "yes" }));
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses an unauthenticated caller", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await PATCH(mkPatch({ storeWorkoutRoutes: true }));
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,236 @@
/**
* `PUT /api/auth/me/ai-usage` — the account's own AI usage level, within the
* operator's ceiling, against the real database and through the real API kit.
*
* Pinned here:
* - a level within the ceiling is stored, audited, and answered as the
* resolved state, with the per-option estimates scaled from the measured
* daily tokens of the last week;
* - a level above the ceiling is refused with 422 `ai.usage.aboveCeiling`
* and the ceiling named, never capped silently, and accepted once the
* operator raises the ceiling;
* - `null` returns to the instance default; fewer than three measured days
* give no estimate;
* - a malformed body is 422 `ai.usage.invalid`; the 31st change in an hour
* is 429 `ai.usage.rateLimited`; an anonymous caller is 401.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { PUT } from "@/app/api/auth/me/ai-usage/route";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/ai-usage";
type UsageOption = {
level: string;
allowed: boolean;
estimatedTokensPerDay: number | null;
};
type AiUsageState = {
level: string;
preference: string | null;
defaultLevel: string;
maxLevel: string;
source: string;
measuredTokensPerDay: number | null;
measuredDays: number;
options: UsageOption[];
};
beforeEach(() => truncateAllTables(prisma));
async function storedLevel(userId: string) {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { aiUsageLevel: true },
});
return row.aiUsageLevel;
}
/** One `coach_usage` row per past day, yesterday first, in UTC. */
async function seedDailyTokens(userId: string, perDay: number[]) {
const today = userDayKey(new Date(), "UTC");
await prisma.coachUsage.createMany({
data: perDay.map((totalTokens, i) => ({
userId,
dateKey: shiftDateKey(today, -(i + 1)),
totalTokens,
})),
});
}
describe("PUT /api/auth/me/ai-usage", () => {
it("stores a level within the ceiling and answers the resolved state", async () => {
const user = await makeUser({ timezone: "UTC" });
await seedDailyTokens(user.id, [90_000, 110_000, 100_000]);
const res = await callRoute<AiUsageState>(PUT, {
method: "PUT",
route: ROUTE,
body: { level: "frugal" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(await storedLevel(user.id)).toBe("FRUGAL");
expect(res.data).toMatchObject({
level: "frugal",
preference: "frugal",
defaultLevel: "standard",
maxLevel: "standard",
source: "user",
measuredTokensPerDay: 100_000,
measuredDays: 3,
});
expect(res.data.options).toEqual([
{ level: "frugal", allowed: true, estimatedTokensPerDay: 100_000 },
{ level: "standard", allowed: true, estimatedTokensPerDay: 200_000 },
{ level: "full", allowed: false, estimatedTokensPerDay: 666_667 },
]);
const audit = await prisma.auditLog.findMany({
where: { userId: user.id, action: "user.aiUsage.update" },
});
expect(audit).toHaveLength(1);
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
previous: null,
next: "frugal",
});
});
it("refuses a level above the ceiling with the ceiling named", async () => {
const user = await makeUser();
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { level: "full" },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(res.meta).toMatchObject({
errorCode: "ai.usage.aboveCeiling",
maxLevel: "standard",
});
expect(await storedLevel(user.id)).toBeNull();
});
it("accepts full once the operator allows it", async () => {
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", aiUsageMaxLevel: "FULL" },
update: { aiUsageMaxLevel: "FULL" },
});
const user = await makeUser();
const res = await callRoute<AiUsageState>(PUT, {
method: "PUT",
route: ROUTE,
body: { level: "full" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data.level).toBe("full");
expect(await storedLevel(user.id)).toBe("FULL");
});
it("returns to the instance default on null", async () => {
const user = await makeUser({ timezone: "UTC", aiUsageLevel: "FRUGAL" });
// Two measured days: fewer than the three an estimate needs.
await seedDailyTokens(user.id, [100_000, 100_000]);
const res = await callRoute<AiUsageState>(PUT, {
method: "PUT",
route: ROUTE,
body: { level: null },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(await storedLevel(user.id)).toBeNull();
expect(res.data).toMatchObject({
level: "standard",
preference: null,
source: "default",
});
expect(res.data.options.map((o) => o.estimatedTokensPerDay)).toEqual([
null,
null,
null,
]);
});
it.each([
[{ level: "lavish" }],
[{}],
[{ level: "frugal", extra: true }],
[{ level: 1 }],
])("refuses %j as invalid and stores nothing", async (body) => {
const user = await makeUser({ aiUsageLevel: "STANDARD" });
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body,
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(res.meta?.errorCode).toBe("ai.usage.invalid");
expect(await storedLevel(user.id)).toBe("STANDARD");
});
it("allows 30 changes an hour and refuses the 31st", async () => {
const user = await makeUser();
const other = await makeUser();
const session = await makeSession(user);
for (let i = 0; i < 30; i++) {
const ok = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { level: i % 2 === 0 ? "frugal" : "standard" },
auth: session,
});
expect(ok.status).toBe(200);
}
const refused = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { level: "frugal" },
auth: session,
});
expect(refused.status).toBe(429);
expect(refused.meta?.errorCode).toBe("ai.usage.rateLimited");
// The 30th change set standard; the refused one did not land.
expect(await storedLevel(user.id)).toBe("STANDARD");
// The window is an hour: the account's bucket in Postgres resets about
// sixty minutes after the first change, not after one.
const [bucket] = await prisma.$queryRaw<Array<{ secondsLeft: number }>>`
SELECT EXTRACT(EPOCH FROM reset_at - (NOW() AT TIME ZONE 'UTC'))::float8
AS "secondsLeft"
FROM rate_limits WHERE key = ${`ai-usage:${user.id}`}
`;
expect(bucket?.secondsLeft).toBeGreaterThan(55 * 60);
expect(bucket?.secondsLeft).toBeLessThanOrEqual(60 * 60);
const neighbour = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { level: "frugal" },
auth: await makeSession(other),
});
expect(neighbour.status).toBe(200);
});
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { level: "frugal" },
auth: anonymous,
});
expect(res.status).toBe(401);
});
});
@@ -0,0 +1,244 @@
/**
* `/api/auth/me/coach-prefs` — the Coach prompt-tuning preferences, against
* the real database and through the real API kit.
*
* Pinned here:
* - a saved account reads its prefs plus the `updatedAt` token; a
* never-saved one reads the pure defaults and no token, because there is
* nothing to guard yet;
* - the PUT stores the defaulted form and mirrors `excludeMetrics` onto
* `insightsExcludeMetrics` in the same write; with a base token the write
* is conditional, and a stale token answers 409 and leaves both columns
* as they were; without one the write is unconditional; a malformed token
* is 422;
* - the thinking depth is stored as chosen, and the wide event names it,
* an absent level as the default; a level outside the set is 422.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET, PUT } from "@/app/api/auth/me/coach-prefs/route";
import { readLogBuffer } from "@/lib/logging/in-memory-buffer";
import { anonymous, callRoute, type RouteResult } from "../../kit/call-route";
import { makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/coach-prefs";
type CoachPrefs = {
tone: string;
excludeMetrics: string[];
reasoning?: string;
updatedAt?: string;
};
beforeEach(() => truncateAllTables(prisma));
async function storedColumns(userId: string) {
return prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
coachPrefsJson: true,
insightsExcludeMetrics: true,
updatedAt: true,
},
});
}
/** The `meta` the route put on its wide event, read from the log ring. */
function putEventMeta(res: RouteResult): Record<string, unknown> | undefined {
const requestId = res.headers.get("x-request-id");
if (!requestId) throw new Error("the response carries no x-request-id");
const [event] = readLogBuffer({
requestId,
action: "auth.me.coach-prefs.put",
});
return event?.meta;
}
describe("GET /api/auth/me/coach-prefs", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
});
it("answers a saved account's prefs plus the optimistic-concurrency token", async () => {
const user = await makeUser({ coachPrefsJson: { tone: "concise" } });
const res = await callRoute<CoachPrefs>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data.tone).toBe("concise");
const row = await storedColumns(user.id);
expect(res.data.updatedAt).toBe(row.updatedAt.toISOString());
});
it("answers the pure defaults with no token for a never-saved account", async () => {
const user = await makeUser();
const res = await callRoute<CoachPrefs>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data.tone).toBe("warm");
expect(res.data.excludeMetrics).toEqual([]);
expect(res.data).not.toHaveProperty("updatedAt");
});
});
describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => {
it("writes both columns under a current token and echoes the fresh one", async () => {
const user = await makeUser({ coachPrefsJson: { tone: "neutral" } });
const session = await makeSession(user);
const read = await callRoute<CoachPrefs>(GET, {
method: "GET",
route: ROUTE,
auth: session,
});
const res = await callRoute<CoachPrefs>(PUT, {
method: "PUT",
route: ROUTE,
body: { excludeMetrics: ["weight"], baseUpdatedAt: read.data.updatedAt },
auth: session,
});
expect(res.status).toBe(200);
const row = await storedColumns(user.id);
expect(row.coachPrefsJson).toMatchObject({
tone: "warm",
excludeMetrics: ["weight"],
});
expect(row.insightsExcludeMetrics).toEqual(["weight"]);
expect(res.data.updatedAt).toBe(row.updatedAt.toISOString());
expect(res.data.updatedAt).not.toBe(read.data.updatedAt);
});
it("answers a stale token with 409 and leaves both columns untouched", async () => {
const user = await makeUser({ coachPrefsJson: { tone: "neutral" } });
const session = await makeSession(user);
const read = await callRoute<CoachPrefs>(GET, {
method: "GET",
route: ROUTE,
auth: session,
});
// Another device saves first, with the same token.
const first = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { excludeMetrics: ["mood"], baseUpdatedAt: read.data.updatedAt },
auth: session,
});
expect(first.status).toBe(200);
const afterFirst = await storedColumns(user.id);
const stale = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { excludeMetrics: ["weight"], baseUpdatedAt: read.data.updatedAt },
auth: session,
});
expect(stale.status).toBe(409);
expect(stale.meta?.errorCode).toBe("coach_prefs_conflict");
expect(await storedColumns(user.id)).toEqual(afterFirst);
expect(afterFirst.insightsExcludeMetrics).toEqual(["mood"]);
});
it("writes unconditionally when the token is omitted", async () => {
const user = await makeUser({ coachPrefsJson: { tone: "neutral" } });
const res = await callRoute<CoachPrefs>(PUT, {
method: "PUT",
route: ROUTE,
body: { excludeMetrics: ["weight"] },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
const row = await storedColumns(user.id);
expect(row.insightsExcludeMetrics).toEqual(["weight"]);
expect(row.coachPrefsJson).toMatchObject({ excludeMetrics: ["weight"] });
expect(res.data.updatedAt).toBe(row.updatedAt.toISOString());
});
it("answers a malformed token with 422 and leaves the row alone", async () => {
const user = await makeUser();
const before = await storedColumns(user.id);
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { excludeMetrics: ["weight"], baseUpdatedAt: "not-a-date" },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(res.meta?.errorCode).toBe("invalid_base_updated_at");
expect(await storedColumns(user.id)).toEqual(before);
});
it("answers a body that is not JSON with 400 and its error code", async () => {
const user = await makeUser();
// The kit serialises every body as JSON, so the unparseable body here is
// the empty one, sent with a JSON content type.
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
});
expect(res.status).toBe(400);
expect(res.meta?.errorCode).toBe("coach-prefs.body.invalid_json");
});
});
describe("PUT /api/auth/me/coach-prefs — thinking depth", () => {
it("stores the chosen level and names it on the wide event", async () => {
const user = await makeUser();
const res = await callRoute<CoachPrefs>(PUT, {
method: "PUT",
route: ROUTE,
body: { reasoning: "high" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data.reasoning).toBe("high");
const row = await storedColumns(user.id);
expect(row.coachPrefsJson).toMatchObject({ reasoning: "high" });
expect(putEventMeta(res)).toMatchObject({ reasoning: "high" });
});
it("reads an absent level as the default on the wide event", async () => {
const user = await makeUser();
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { tone: "warm" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(putEventMeta(res)).toMatchObject({ reasoning: "medium" });
});
it.each(["xhigh", "none", 2, null])(
"refuses %s as a level with 422 and stores nothing",
async (value) => {
const user = await makeUser();
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { reasoning: value },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(res.meta?.errorCode).toBe("coach-prefs.body.invalid_shape");
const row = await storedColumns(user.id);
expect(row.coachPrefsJson).toBeNull();
},
);
});
@@ -0,0 +1,175 @@
/**
* `/api/auth/me/diabetes` — the declared diabetes flag, against the real
* database and through the real API kit.
*
* The flag switches every glucose surface from the general normal bands to
* the glycemic goal bands and reaches the Coach snapshot; the web toggle in
* the thresholds section rides this route. Pinned here:
* - the read answers false for an account that never declared it and true
* once it is stored;
* - the write stores the flag both ways, answers it and audits previous and
* next; a missing, non-boolean or unknown field is 422 and leaves the
* column alone;
* - the write bucket allows 60 a minute per account.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET, PATCH } from "@/app/api/auth/me/diabetes/route";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/diabetes";
type Diabetes = { hasDiabetes: boolean };
beforeEach(() => truncateAllTables(prisma));
async function storedFlag(userId: string): Promise<boolean> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { hasDiabetes: true },
});
return row.hasDiabetes;
}
describe("GET /api/auth/me/diabetes", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("answers false for an account that never declared the flag", async () => {
const user = await makeUser();
const res = await callRoute<Diabetes>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ hasDiabetes: false });
});
it("answers true once the flag is stored", async () => {
const user = await makeUser({ hasDiabetes: true });
const res = await callRoute<Diabetes>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ hasDiabetes: true });
});
});
describe("PATCH /api/auth/me/diabetes", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { hasDiabetes: true },
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("turns the flag on, answers it, audits the transition and reads back", async () => {
const user = await makeUser();
const session = await makeSession(user);
const res = await callRoute<Diabetes>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { hasDiabetes: true },
auth: session,
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ hasDiabetes: true });
expect(await storedFlag(user.id)).toBe(true);
const audit = await prisma.auditLog.findMany({
where: { userId: user.id, action: "user.diabetes.update" },
});
expect(audit).toHaveLength(1);
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
previous: false,
next: true,
});
const read = await callRoute<Diabetes>(GET, {
method: "GET",
route: ROUTE,
auth: session,
});
expect(read.data).toEqual({ hasDiabetes: true });
});
it("turns the flag off and answers the resolved state", async () => {
const user = await makeUser({ hasDiabetes: true });
const res = await callRoute<Diabetes>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { hasDiabetes: false },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ hasDiabetes: false });
expect(await storedFlag(user.id)).toBe(false);
});
it.each([
["a missing hasDiabetes field", { otherField: true }],
["a non-boolean hasDiabetes", { hasDiabetes: "true" }],
["an undeclared key beside it", { hasDiabetes: true, extra: 1 }],
])("refuses %s with 422 and stores nothing", async (_, body) => {
const user = await makeUser();
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body,
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(await storedFlag(user.id)).toBe(false);
});
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
const user = await makeUser();
const other = await makeUser();
const session = await makeSession(user);
for (let i = 0; i < 60; i++) {
const ok = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { hasDiabetes: i % 2 === 0 },
auth: session,
});
expect(ok.status).toBe(200);
}
const refused = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { hasDiabetes: true },
auth: session,
});
expect(refused.status).toBe(429);
// The 60th write set false; the refused one did not land.
expect(await storedFlag(user.id)).toBe(false);
const neighbour = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { hasDiabetes: true },
auth: await makeSession(other),
});
expect(neighbour.status).toBe(200);
});
});
@@ -0,0 +1,276 @@
/**
* `/api/auth/me/disable-coach` — the per-account Coach opt-out, against the
* real database and through the real API kit.
*
* Pinned here:
* - the read answers the stored flag, false on a fresh account;
* - the write stores the flag, answers the next state, and audits it under
* an enable or disable action with previous and next, also when the value
* does not change (the audit row mirrors the call);
* - a missing or non-boolean field, an undeclared key beside a valid one,
* and a body that is not JSON are refused without touching the row; the
* 422 names the undeclared key without reflecting a hostile one verbatim;
* - the write bucket allows 60 a minute per account.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET, PATCH } from "@/app/api/auth/me/disable-coach/route";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/disable-coach";
type DisableCoach = { disableCoach: boolean };
beforeEach(() => truncateAllTables(prisma));
async function storedFlag(userId: string): Promise<boolean> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { disableCoach: true },
});
return row.disableCoach;
}
async function auditRows(userId: string) {
const rows = await prisma.auditLog.findMany({
where: { userId, action: { startsWith: "user.disable-coach." } },
orderBy: { createdAt: "asc" },
});
return rows.map((r) => ({
action: r.action,
details: JSON.parse(r.details ?? "null"),
}));
}
describe("GET /api/auth/me/disable-coach", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("answers false for a fresh account", async () => {
const user = await makeUser();
const res = await callRoute<DisableCoach>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ disableCoach: false });
});
it("answers true for an account that opted out", async () => {
const user = await makeUser({ disableCoach: true });
const res = await callRoute<DisableCoach>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ disableCoach: true });
});
});
describe("PATCH /api/auth/me/disable-coach", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true },
auth: anonymous,
});
expect(res.status).toBe(401);
});
it("turns the flag on and audits the enable", async () => {
const user = await makeUser();
const res = await callRoute<DisableCoach>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ disableCoach: true });
expect(await storedFlag(user.id)).toBe(true);
expect(await auditRows(user.id)).toEqual([
{
action: "user.disable-coach.enable",
details: { previous: false, next: true },
},
]);
});
it("turns the flag off and audits the disable", async () => {
const user = await makeUser({ disableCoach: true });
const res = await callRoute<DisableCoach>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: false },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ disableCoach: false });
expect(await storedFlag(user.id)).toBe(false);
expect(await auditRows(user.id)).toEqual([
{
action: "user.disable-coach.disable",
details: { previous: true, next: false },
},
]);
});
it("writes through and audits when the flag already holds the value", async () => {
const user = await makeUser({ disableCoach: true });
const res = await callRoute<DisableCoach>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(await storedFlag(user.id)).toBe(true);
expect(await auditRows(user.id)).toEqual([
{
action: "user.disable-coach.enable",
details: { previous: true, next: true },
},
]);
});
it.each([
["a missing field", { otherField: "ignored" }],
["a non-boolean field", { disableCoach: "true" }],
])("refuses %s with 422 and stores nothing", async (_label, body) => {
const user = await makeUser();
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body,
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(await storedFlag(user.id)).toBe(false);
expect(await auditRows(user.id)).toEqual([]);
});
// 400 with a machine code in `meta.errorCode`, like every route that reads
// through `safeJson`; this one used to answer 422.
it("refuses a body that is not JSON with 400 and its error code", async () => {
const user = await makeUser();
// The kit serialises every body as JSON, so the unparseable body here is
// the empty one, sent with a JSON content type.
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
contract: {
skip: "the registry declares no 400 for this PATCH, which answers one for a body that is not JSON",
},
});
expect(res.status).toBe(400);
expect(res.error).toBe("Invalid JSON body");
expect(res.meta?.errorCode).toBe("disable-coach.body.invalid_json");
expect(await storedFlag(user.id)).toBe(false);
});
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
const user = await makeUser();
const other = await makeUser();
const session = await makeSession(user);
for (let i = 0; i < 60; i++) {
const ok = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: i % 2 === 0 },
auth: session,
});
expect(ok.status).toBe(200);
}
const refused = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true },
auth: session,
});
expect(refused.status).toBe(429);
// The 60th write turned it off; the refused one did not land.
expect(await storedFlag(user.id)).toBe(false);
expect(await auditRows(user.id)).toHaveLength(60);
const neighbour = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true },
auth: await makeSession(other),
});
expect(neighbour.status).toBe(200);
});
});
/**
* The body schema is `.strict()`: a key the route does not declare is refused
* rather than trimmed away. The body worth pinning is the one that used to
* succeed while doing something other than what it said:
* `{ disableCoach: true, disableCoachh: false }` wrote `true` and answered 200.
*/
describe("PATCH /api/auth/me/disable-coach — unknown keys", () => {
it("refuses an undeclared key beside a valid one and writes nothing", async () => {
const user = await makeUser();
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true, disableCoachh: false },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(await storedFlag(user.id)).toBe(false);
expect(await auditRows(user.id)).toEqual([]);
});
it("names the offending key in the 422", async () => {
const user = await makeUser();
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true, disableCoachh: false },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(res.body).toMatchObject({
details: {
issues: expect.arrayContaining([
expect.objectContaining({
code: "unrecognized_keys",
keys: ["disableCoachh"],
message: expect.stringContaining("disableCoachh"),
}),
]),
},
});
});
it("does not reflect a hostile key name back verbatim", async () => {
const user = await makeUser();
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true, "<script>alert(1)</script>": 1 },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
const raw = JSON.stringify(res.body);
expect(raw).not.toContain("<script>");
expect(raw).toContain("script_alert_1");
});
});
@@ -0,0 +1,354 @@
/**
* `/api/auth/me/documents-auto-ai-read` — the opt-in to read documents with
* AI on upload, against the real database, the real capability resolver and
* a real pg-boss queue.
*
* Turning the switch on is the standing consent act, so the write mints an
* `ai_extraction` receipt (document reading only, never the master `ai_full`
* that would also open the Coach and the AI analysis). And because the
* summary job is enqueued at upload time and does nothing while the switch is
* off, a genuine off-to-on flip schedules one catch-up pass over the vault;
* without it a person who filled the vault first saw the switch do nothing.
* Pinned here:
* - the flip stores the flag, mints exactly one extraction receipt and
* queues exactly one catch-up, and the capability is read after the mint,
* so the receipt the same request wrote opens it;
* - re-saving on, or turning it off, queues nothing, and off mints nothing;
* - an active receipt that already covers document reads is not doubled;
* - a receipt minted concurrently by another request is a success, not a
* 500;
* - with document reading closed by the operator the flag is still saved
* and no catch-up is queued.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { PgBoss } from "pg-boss";
import type { Prisma, User } from "@/generated/prisma/client";
import { GET, PATCH } from "@/app/api/auth/me/documents-auto-ai-read/route";
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
import {
DOCUMENT_SUMMARY_CATCHUP_QUEUE,
enqueueSummaryCatchUp,
} from "@/lib/jobs/document-summary-catchup";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeSession, makeUser, uniqueSuffix } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/documents-auto-ai-read";
type AutoRead = { documentsAutoAiRead: boolean };
let boss: PgBoss;
beforeAll(async () => {
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
await boss.start();
await boss.createQueue(DOCUMENT_SUMMARY_CATCHUP_QUEUE);
setGlobalBoss(boss);
}, 120_000);
afterAll(async () => {
setGlobalBoss(null);
await boss?.stop({ graceful: false });
});
beforeEach(() => truncateAllTables(prisma));
/**
* An account with its own provider key, so the only thing between it and
* document reading is the consent receipt the switch mints.
*/
async function makeReader(
overrides: Partial<Prisma.UserUncheckedCreateInput> = {},
): Promise<User> {
return makeUser({
aiProvider: "ANTHROPIC",
aiModel: "claude-sonnet-4-6",
// Presence only: the capability probe never decrypts it.
aiAnthropicKeyEncrypted: "v1:presence-only",
...overrides,
});
}
async function catchUpJobs(userId: string): Promise<number> {
const rows = await prisma.$queryRaw<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM pgboss.job
WHERE name = ${DOCUMENT_SUMMARY_CATCHUP_QUEUE}
AND singleton_key = ${`document-summary-catchup|${userId}`}
`;
return rows[0]?.n ?? 0;
}
/** The catch-up is fire-and-forget; wait for its job to land on the queue. */
async function waitForCatchUp(userId: string): Promise<void> {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
if ((await catchUpJobs(userId)) > 0) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`no catch-up job for ${userId}`);
}
/**
* Before asserting that a write queued nothing: send a catch-up of our own
* through the same queue and let it land, so a send the route had started
* before returning has had the same time to land too.
*/
async function settleQueue(): Promise<void> {
const probe = `probe-${uniqueSuffix()}`;
await enqueueSummaryCatchUp(probe);
await waitForCatchUp(probe);
await new Promise((resolve) => setTimeout(resolve, 200));
}
async function storedFlag(userId: string): Promise<boolean> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { documentsAutoAiRead: true },
});
return row.documentsAutoAiRead;
}
async function activeReceipts(userId: string): Promise<string[]> {
const rows = await prisma.consentReceipt.findMany({
where: { userId, revokedAt: null },
select: { kind: true },
orderBy: { kind: "asc" },
});
return rows.map((r) => r.kind);
}
async function auditDetails(userId: string): Promise<unknown[]> {
const rows = await prisma.auditLog.findMany({
where: { userId, action: "user.documentsAutoAiRead.update" },
orderBy: { createdAt: "asc" },
});
return rows.map((r) => JSON.parse(r.details ?? "null"));
}
function patch(user: Pick<User, "id">, documentsAutoAiRead: boolean) {
return makeSession(user).then((auth) =>
callRoute<AutoRead>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { documentsAutoAiRead },
auth,
}),
);
}
describe("GET /api/auth/me/documents-auto-ai-read", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("answers off for a fresh account and on once it is stored", async () => {
const fresh = await makeUser();
const opted = await makeUser({ documentsAutoAiRead: true });
const off = await callRoute<AutoRead>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(fresh),
});
const on = await callRoute<AutoRead>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(opted),
});
expect(off.data).toEqual({ documentsAutoAiRead: false });
expect(on.data).toEqual({ documentsAutoAiRead: true });
});
});
describe("PATCH /api/auth/me/documents-auto-ai-read", () => {
it("on an off-to-on flip stores the flag, mints the extraction receipt and queues one catch-up", async () => {
const user = await makeReader();
// Without a receipt, document reading is closed for this account: the
// catch-up below can only be queued because the capability is read after
// the receipt this request mints.
expect((await aiCapabilityForJob(user.id, "documentAi")).available).toBe(
false,
);
const res = await patch(user, true);
expect(res.status).toBe(200);
expect(res.data).toEqual({ documentsAutoAiRead: true });
expect(await storedFlag(user.id)).toBe(true);
expect(await activeReceipts(user.id)).toEqual(["ai_extraction"]);
await waitForCatchUp(user.id);
await settleQueue();
expect(await catchUpJobs(user.id)).toBe(1);
expect(await auditDetails(user.id)).toEqual([
{ previous: false, next: true, consentMinted: true },
]);
});
it("queues no second catch-up when the switch was already on", async () => {
// Re-saving an already-on setting must not queue a second pass over the
// same documents.
const user = await makeReader({ documentsAutoAiRead: true });
await prisma.consentReceipt.create({
data: {
userId: user.id,
kind: "ai_extraction",
artefact: "earlier flip",
signedAt: new Date(),
},
});
const res = await patch(user, true);
expect(res.status).toBe(200);
await settleQueue();
expect(await catchUpJobs(user.id)).toBe(0);
expect(await activeReceipts(user.id)).toEqual(["ai_extraction"]);
});
it("turning it off stores off, queues nothing and mints nothing", async () => {
const user = await makeReader({ documentsAutoAiRead: true });
const res = await patch(user, false);
expect(res.status).toBe(200);
expect(res.data).toEqual({ documentsAutoAiRead: false });
expect(await storedFlag(user.id)).toBe(false);
await settleQueue();
expect(await catchUpJobs(user.id)).toBe(0);
expect(await activeReceipts(user.id)).toEqual([]);
});
it.each(["ai_extraction", "ai_full"])(
"mints no second receipt when an active %s already covers document reads",
async (kind) => {
const user = await makeReader();
await prisma.consentReceipt.create({
data: {
userId: user.id,
kind,
artefact: "earlier grant",
signedAt: new Date(),
},
});
const res = await patch(user, true);
expect(res.status).toBe(200);
expect(await activeReceipts(user.id)).toEqual([kind]);
expect(await auditDetails(user.id)).toEqual([
{ previous: false, next: true, consentMinted: false },
]);
},
);
it("mints a fresh receipt when the earlier one was revoked", async () => {
const user = await makeReader();
await prisma.consentReceipt.create({
data: {
userId: user.id,
kind: "ai_extraction",
artefact: "earlier grant",
signedAt: new Date("2026-01-01T00:00:00Z"),
revokedAt: new Date("2026-02-01T00:00:00Z"),
},
});
const res = await patch(user, true);
expect(res.status).toBe(200);
expect(await activeReceipts(user.id)).toEqual(["ai_extraction"]);
});
it("treats a receipt minted concurrently by another request as success", async () => {
// Another request inserts the receipt and has not committed yet: this
// request's lookup cannot see it, its insert waits on the active-receipt
// unique index, and fails once the other commits.
const user = await makeReader();
const session = await makeSession(user);
// Wrapped in an object so the transaction does not wait on the request,
// which itself waits on the transaction's commit.
const { pending } = await prisma.$transaction(
async (tx) => {
await tx.consentReceipt.create({
data: {
userId: user.id,
kind: "ai_extraction",
artefact: "concurrent flip",
signedAt: new Date(),
},
});
const request = callRoute<AutoRead>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { documentsAutoAiRead: true },
auth: session,
});
const deadline = Date.now() + 10_000;
for (;;) {
const waiting = await prisma.$queryRaw<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND query ILIKE '%INSERT INTO%consent_receipts%'
`;
if ((waiting[0]?.n ?? 0) > 0) break;
if (Date.now() > deadline) {
throw new Error("the route's receipt insert never waited");
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
return { pending: request };
},
{ timeout: 20_000 },
);
const res = await pending;
expect(res.status).toBe(200);
expect(res.data).toEqual({ documentsAutoAiRead: true });
expect(await storedFlag(user.id)).toBe(true);
expect(await activeReceipts(user.id)).toEqual(["ai_extraction"]);
expect(await auditDetails(user.id)).toEqual([
{ previous: false, next: true, consentMinted: false },
]);
});
it("saves the flag but queues no catch-up while the operator has document reading closed", async () => {
// Every summary the pass would enqueue could only be refused, so the pass
// is not queued at all.
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", assistantDocumentAiEnabled: false },
update: { assistantDocumentAiEnabled: false },
});
const user = await makeReader();
const res = await patch(user, true);
expect(res.status).toBe(200);
expect(await storedFlag(user.id)).toBe(true);
await settleQueue();
expect(await catchUpJobs(user.id)).toBe(0);
});
it("refuses a non-boolean flag with 422 and stores nothing", async () => {
const user = await makeReader();
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { documentsAutoAiRead: "yes" },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(await storedFlag(user.id)).toBe(false);
expect(await activeReceipts(user.id)).toEqual([]);
});
});
@@ -0,0 +1,274 @@
/**
* `/api/auth/me/glucose-unit` — the blood-glucose display unit, against the
* real database and through the real API kit.
*
* `User.glucoseUnit` is read by about thirty surfaces; this route is its one
* writer. Pinned here:
* - the read resolves an account that never chose to mg/dL and reads back
* a chosen mmol/L;
* - the write stores the unit, answers it and audits previous and next;
* a unit outside the two the app renders, or a missing field, is 422 and
* leaves the column alone, because a stray string there would resolve to
* mg/dL and quietly put an mmol/L reader back on the wrong number;
* - a write that changes the unit re-warms the stored texts that quote it
* (here: the cached daily briefing, a force-warm job on the real queue),
* and saving the same unit again does not;
* - the write bucket allows 60 a minute per account.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { PgBoss } from "pg-boss";
import { GET, PATCH } from "@/app/api/auth/me/glucose-unit/route";
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
import { INSIGHT_PREGENERATE_QUEUE } from "@/lib/jobs/insight-pregenerate-shared";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/glucose-unit";
type GlucoseUnit = { glucoseUnit: "mg/dL" | "mmol/L" };
let boss: PgBoss;
beforeAll(async () => {
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
await boss.start();
await boss.createQueue(INSIGHT_PREGENERATE_QUEUE);
setGlobalBoss(boss);
}, 120_000);
afterAll(async () => {
setGlobalBoss(null);
await boss?.stop({ graceful: false });
});
beforeEach(() => truncateAllTables(prisma));
async function forceWarmJobs(userId: string): Promise<number> {
const rows = await prisma.$queryRaw<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM pgboss.job
WHERE name = ${INSIGHT_PREGENERATE_QUEUE}
AND singleton_key = ${`force:${userId}`}
`;
return rows[0]?.n ?? 0;
}
/** The re-warm is fire-and-forget; wait for its job to land on the queue. */
async function waitForForceWarm(userId: string): Promise<void> {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
if ((await forceWarmJobs(userId)) > 0) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`no force-warm job for ${userId}`);
}
async function storedUnit(userId: string): Promise<string | null> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { glucoseUnit: true },
});
return row.glucoseUnit;
}
describe("GET /api/auth/me/glucose-unit", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("resolves an account that never chose to mg/dL", async () => {
const user = await makeUser({ glucoseUnit: null });
const res = await callRoute<GlucoseUnit>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ glucoseUnit: "mg/dL" });
});
it("reads back the unit the account chose", async () => {
const user = await makeUser({ glucoseUnit: "mmol/L" });
const res = await callRoute<GlucoseUnit>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.data).toEqual({ glucoseUnit: "mmol/L" });
});
});
describe("PATCH /api/auth/me/glucose-unit", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { glucoseUnit: "mmol/L" },
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("moves an account to mmol/L, answers it and audits the transition", async () => {
const user = await makeUser({ glucoseUnit: null });
const session = await makeSession(user);
const res = await callRoute<GlucoseUnit>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { glucoseUnit: "mmol/L" },
auth: session,
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ glucoseUnit: "mmol/L" });
expect(await storedUnit(user.id)).toBe("mmol/L");
const audit = await prisma.auditLog.findMany({
where: { userId: user.id, action: "user.glucose-unit.update" },
});
expect(audit).toHaveLength(1);
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
previous: "mg/dL",
next: "mmol/L",
});
const read = await callRoute<GlucoseUnit>(GET, {
method: "GET",
route: ROUTE,
auth: session,
});
expect(read.data).toEqual({ glucoseUnit: "mmol/L" });
});
it("moves an account back to mg/dL", async () => {
const user = await makeUser({ glucoseUnit: "mmol/L" });
const res = await callRoute<GlucoseUnit>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { glucoseUnit: "mg/dL" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ glucoseUnit: "mg/dL" });
expect(await storedUnit(user.id)).toBe("mg/dL");
});
it("refuses a unit outside the two the app renders and stores nothing", async () => {
const user = await makeUser({ glucoseUnit: "mmol/L" });
const session = await makeSession(user);
for (const bad of ["mmol/l", "mgdl", "imperial", "", 1]) {
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { glucoseUnit: bad },
auth: session,
});
expect(res.status).toBe(422);
}
expect(await storedUnit(user.id)).toBe("mmol/L");
});
it("refuses a body without the glucoseUnit field with 422", async () => {
const user = await makeUser({ glucoseUnit: null });
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { otherField: "mmol/L" },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(await storedUnit(user.id)).toBeNull();
});
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
const user = await makeUser();
const other = await makeUser();
const session = await makeSession(user);
for (let i = 0; i < 60; i++) {
const ok = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { glucoseUnit: i % 2 === 0 ? "mmol/L" : "mg/dL" },
auth: session,
});
expect(ok.status).toBe(200);
}
const refused = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { glucoseUnit: "mmol/L" },
auth: session,
});
expect(refused.status).toBe(429);
expect(refused.headers.get("retry-after")).not.toBeNull();
// The 60th write set mg/dL; the refused one did not land.
expect(await storedUnit(user.id)).toBe("mg/dL");
const neighbour = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { glucoseUnit: "mmol/L" },
auth: await makeSession(other),
});
expect(neighbour.status).toBe(200);
});
});
describe("PATCH re-warms the texts that quote the unit", () => {
it("queues a briefing re-warm when the unit actually changes", async () => {
const user = await makeUser({
glucoseUnit: null,
insightsCachedAt: new Date(),
});
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { glucoseUnit: "mmol/L" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
await waitForForceWarm(user.id);
});
it("queues nothing when the same unit is saved again", async () => {
const unchanged = await makeUser({
glucoseUnit: "mmol/L",
insightsCachedAt: new Date(),
});
const changed = await makeUser({
glucoseUnit: "mg/dL",
insightsCachedAt: new Date(),
});
const same = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { glucoseUnit: "mmol/L" },
auth: await makeSession(unchanged),
});
expect(same.status).toBe(200);
// A second account that does change its unit, sent after the first: once
// its job is on the queue, a re-warm the first write had started would
// have landed as well.
await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { glucoseUnit: "mmol/L" },
auth: await makeSession(changed),
});
await waitForForceWarm(changed.id);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(await forceWarmJobs(unchanged.id)).toBe(0);
});
});
@@ -0,0 +1,483 @@
/**
* `/api/auth/me/health-score-config` — which pillars count toward the Health
* Score, against the real database and through the real API kit.
*
* Pinned here:
* - the read answers every pillar counting and no selection for a fresh
* account (and no concurrency token), and a stored recipe as the pillars
* it leaves counting, with the row's token;
* - the write takes the positive selection, stores its complement with a
* recipe version that rises by one on every accepted write, keeps a
* keep-everything selection as an authored choice, audits it, and evicts
* the account's cached score;
* - since v1.38 the only selection refused for breadth is the empty one;
* two areas, one area and one pillar all save;
* - an unknown pillar, an unknown key, a `userId`, a body that is not JSON
* and a stale base token are refused without a write or an eviction;
* - the write bucket allows 60 a minute per account;
* - the published contract names every error code the route emits.
*
* The invalidation cases are here because a missing one is invisible: the
* write succeeds, the response is right, and the person keeps seeing the old
* number for an hour.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET, PATCH } from "@/app/api/auth/me/health-score-config/route";
import type { Prisma } from "@/generated/prisma/client";
import type { ScorePillarId } from "@/lib/analytics/score/types";
import { caches } from "@/lib/cache/server-cache";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
import { anonymous, callRoute } from "../../kit/call-route";
import {
makeSession,
makeUser,
type SessionFixture,
} from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/health-score-config";
const ALL_PILLARS: ScorePillarId[] = [
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"LIPIDS",
];
interface ResolvedBody {
pillars: ScorePillarId[];
excludedPillars: ScorePillarId[];
hasSelection: boolean;
version: number;
changedAt: string | null;
updatedAt?: string;
}
interface StoredConfig {
excludedPillars: ScorePillarId[];
version: number;
changedAt: string;
}
beforeEach(() => truncateAllTables(prisma));
function isStoredConfig(value: unknown): value is StoredConfig {
return (
typeof value === "object" &&
value !== null &&
"excludedPillars" in value &&
"version" in value &&
"changedAt" in value
);
}
async function storedConfig(userId: string): Promise<StoredConfig | null> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { healthScoreConfigJson: true },
});
const value: unknown = row.healthScoreConfigJson;
if (value === null) return null;
if (!isStoredConfig(value)) throw new Error("unexpected stored config");
return value;
}
async function storedUpdatedAt(userId: string): Promise<Date> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { updatedAt: true },
});
return row.updatedAt;
}
async function userWithConfig(
config: Prisma.InputJsonValue | undefined = undefined,
) {
return makeUser(
config === undefined ? {} : { healthScoreConfigJson: config },
);
}
function patch(auth: SessionFixture, body: unknown) {
return callRoute<ResolvedBody>(PATCH, {
method: "PATCH",
route: ROUTE,
body,
auth,
});
}
function seedScoreCache(userId: string): void {
caches.analytics.set(`${userId}|score`, { score: 71 });
caches.insightsDerived.set(`${userId}|derived`, { score: 71 });
}
function scoreCacheEvicted(userId: string): boolean {
return (
caches.analytics.get(`${userId}|score`) === null &&
caches.insightsDerived.get(`${userId}|derived`) === null
);
}
describe("GET /api/auth/me/health-score-config", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("answers every pillar counting and no selection for a fresh account", async () => {
const user = await userWithConfig();
const res = await callRoute<ResolvedBody>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data.pillars).toEqual(ALL_PILLARS);
expect(res.data.hasSelection).toBe(false);
expect(res.data.version).toBe(0);
// Nothing to guard a first write against, so no token.
expect(res.data).not.toHaveProperty("updatedAt");
});
it("answers a stored recipe as the pillars it leaves counting, with the row's token", async () => {
const user = await userWithConfig({
excludedPillars: ["SLEEP", "LIPIDS"],
version: 2,
changedAt: "2026-07-30T08:00:00.000Z",
});
const res = await callRoute<ResolvedBody>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.data.pillars).toEqual(
ALL_PILLARS.filter((id) => id !== "SLEEP" && id !== "LIPIDS"),
);
expect(res.data.excludedPillars).toEqual(["SLEEP", "LIPIDS"]);
expect(res.data.hasSelection).toBe(true);
expect(res.data.version).toBe(2);
expect(res.data.updatedAt).toBe(
(await storedUpdatedAt(user.id)).toISOString(),
);
});
});
describe("PATCH /api/auth/me/health-score-config", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { pillars: ALL_PILLARS },
auth: anonymous,
});
expect(res.status).toBe(401);
expect(await prisma.auditLog.count()).toBe(0);
});
it("stores the complement of the selection, answers the selection and audits it", async () => {
const user = await userWithConfig();
const selection: ScorePillarId[] = [
"BLOOD_PRESSURE",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
];
const res = await patch(await makeSession(user), { pillars: selection });
expect(res.status).toBe(200);
expect(res.data.pillars).toEqual(selection);
expect(res.data.hasSelection).toBe(true);
expect(res.data.updatedAt).toBe(
(await storedUpdatedAt(user.id)).toISOString(),
);
const stored = await storedConfig(user.id);
expect(stored?.excludedPillars).toEqual(
ALL_PILLARS.filter((id) => !selection.includes(id)),
);
expect(stored?.version).toBe(1);
expect(stored?.changedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
const audit = await prisma.auditLog.findMany({
where: { userId: user.id, action: "user.health_score_config.update" },
});
expect(audit).toHaveLength(1);
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
counted: selection,
excluded: ALL_PILLARS.filter((id) => !selection.includes(id)),
version: 1,
});
});
it("raises the recipe version by one on every accepted write", async () => {
const user = await userWithConfig({
excludedPillars: ["LIPIDS"],
version: 7,
changedAt: "2026-07-01T00:00:00.000Z",
});
const session = await makeSession(user);
await patch(session, { pillars: ALL_PILLARS });
expect((await storedConfig(user.id))?.version).toBe(8);
await patch(session, { pillars: ["SLEEP"] });
expect((await storedConfig(user.id))?.version).toBe(9);
});
it("keeps a keep-everything selection as an authored choice", async () => {
const user = await userWithConfig();
const res = await patch(await makeSession(user), { pillars: ALL_PILLARS });
expect(res.data.hasSelection).toBe(true);
expect((await storedConfig(user.id))?.excludedPillars).toEqual([]);
});
it("evicts the account's score caches so the old number is not served on", async () => {
const user = await userWithConfig();
const other = await makeUser();
seedScoreCache(user.id);
seedScoreCache(other.id);
const res = await patch(await makeSession(user), { pillars: ALL_PILLARS });
expect(res.status).toBe(200);
expect(scoreCacheEvicted(user.id)).toBe(true);
expect(scoreCacheEvicted(other.id)).toBe(false);
});
});
/**
* Since v1.38 the breadth floor is a recommendation: the scorer computes
* from whatever is readable and labels how broad the set was. The empty
* selection stays refused, because there is nothing left to average.
*/
describe("PATCH: the breadth rule", () => {
it.each<[string, ScorePillarId[]]>([
["activity and wellbeing alone", ["ACTIVITY", "WELLBEING"]],
[
"the cardiometabolic triple, three pillars in one area",
["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"],
],
["a single pillar", ["ACTIVITY"]],
["a three-area selection", ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"]],
])("accepts %s, stores it and evicts the score", async (_, pillars) => {
const user = await userWithConfig();
seedScoreCache(user.id);
const res = await patch(await makeSession(user), { pillars });
expect(res.status).toBe(200);
expect(res.data.pillars).toEqual(pillars);
expect((await storedConfig(user.id))?.excludedPillars).toEqual(
ALL_PILLARS.filter((id) => !pillars.includes(id)),
);
expect(scoreCacheEvicted(user.id)).toBe(true);
});
it("refuses an empty selection rather than storing a score that cannot exist", async () => {
const user = await userWithConfig();
seedScoreCache(user.id);
const res = await patch(await makeSession(user), { pillars: [] });
expect(res.status).toBe(422);
expect(res.meta?.errorCode).toBe("health_score_config.too_narrow");
expect(res.meta?.reason).toBe("no_pillars_selected");
expect(res.error).toContain("at least one area of health");
expect(await storedConfig(user.id)).toBeNull();
expect(scoreCacheEvicted(user.id)).toBe(false);
});
});
describe("PATCH: envelope and transport", () => {
it.each<[string, Record<string, unknown>]>([
[
"an unknown pillar id",
{ pillars: ["BLOOD_PRESSURE", "MOON_PHASE", "SLEEP"] },
],
["an unknown body key", { pillars: ALL_PILLARS, weights: { SLEEP: 2 } }],
["a userId in the body", { pillars: ALL_PILLARS, userId: "someone-else" }],
])("refuses %s with 422 and writes nothing", async (_, body) => {
const user = await userWithConfig();
const res = await patch(await makeSession(user), body);
expect(res.status).toBe(422);
expect(res.meta?.errorCode).toBe("health_score_config.invalid");
expect(await storedConfig(user.id)).toBeNull();
});
it("refuses a body that is not JSON with 422", async () => {
const user = await userWithConfig();
// The kit serialises every body as JSON, so the unparseable body here is
// the empty one, sent with a JSON content type.
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(res.error).toBe("health-score-config.body.invalid_json");
expect(await storedConfig(user.id)).toBeNull();
});
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
const user = await userWithConfig();
const session = await makeSession(user);
for (let i = 0; i < 60; i++) {
const ok = await patch(session, { pillars: ALL_PILLARS });
expect(ok.status).toBe(200);
}
const refused = await patch(session, { pillars: ["SLEEP"] });
expect(refused.status).toBe(429);
const stored = await storedConfig(user.id);
expect(stored?.version).toBe(60);
expect(stored?.excludedPillars).toEqual([]);
});
it("answers a stale base token with 409, writes nothing and keeps the caches", async () => {
const user = await userWithConfig({
excludedPillars: ["LIPIDS"],
version: 3,
changedAt: "2026-07-01T00:00:00.000Z",
});
const session = await makeSession(user);
const read = await callRoute<ResolvedBody>(GET, {
method: "GET",
route: ROUTE,
auth: session,
});
const base = read.data.updatedAt;
expect(base).toBeDefined();
// Another device saves first, with the same token.
const first = await patch(session, {
pillars: ALL_PILLARS,
baseUpdatedAt: base,
});
expect(first.status).toBe(200);
seedScoreCache(user.id);
const second = await patch(session, {
pillars: ["SLEEP"],
baseUpdatedAt: base,
});
expect(second.status).toBe(409);
expect(second.meta?.errorCode).toBe("health_score_config_conflict");
const stored = await storedConfig(user.id);
expect(stored?.version).toBe(4);
expect(stored?.excludedPillars).toEqual([]);
expect(scoreCacheEvicted(user.id)).toBe(false);
});
});
/**
* The route shipped once with a settings surface in front of it and no
* published contract. The kit's contract check proves each body matches its
* schema; this block proves the descriptions a client reads name every error
* code the real route emits, on the status it emits it with.
*/
describe("the published contract names what the route serves", () => {
type Doc = {
paths?: Record<
string,
Record<string, { responses?: Record<string, { description?: string }> }>
>;
components?: {
schemas?: Record<
string,
{ properties?: Record<string, unknown>; required?: string[] }
>;
};
};
function isDoc(value: unknown): value is Doc {
return typeof value === "object" && value !== null;
}
function doc(): Doc {
const built: unknown = buildOpenApiDocument();
if (!isDoc(built)) throw new Error("no OpenAPI document");
return built;
}
it("registers both verbs", () => {
expect(Object.keys(doc().paths?.[ROUTE] ?? {}).sort()).toEqual([
"get",
"patch",
]);
});
it("publishes exactly the fields a real write answers, updatedAt optional", async () => {
// The kit refuses a key the schema does not declare; this is the other
// direction, a published field the route never sends.
const user = await userWithConfig();
const res = await patch(await makeSession(user), { pillars: ALL_PILLARS });
expect(res.status).toBe(200);
const schema = doc().components?.schemas?.["HealthScoreConfigResponse"];
expect(schema, "HealthScoreConfigResponse is not emitted").toBeDefined();
expect(Object.keys(schema?.properties ?? {}).sort()).toEqual(
Object.keys(res.data).sort(),
);
// Absent on a never-configured read (proven by the GET case above, which
// the kit parses against this schema), so it must not be required.
expect(schema?.required ?? []).not.toContain("updatedAt");
});
it("names every errorCode the route can emit, on the right status", async () => {
const emitted = new Map<number, Set<string>>();
const record = async (
res: Awaited<ReturnType<typeof patch>>,
): Promise<void> => {
const code = res.meta?.errorCode;
if (typeof code !== "string") return;
const set = emitted.get(res.status) ?? new Set<string>();
set.add(code);
emitted.set(res.status, set);
};
const user = await userWithConfig();
const session = await makeSession(user);
// Too narrow: the empty selection.
await record(await patch(session, { pillars: [] }));
// A body that fails validation.
await record(await patch(session, { pillars: ["NOT_A_PILLAR"] }));
// A present but unparseable base token.
await record(
await patch(session, { pillars: ALL_PILLARS, baseUpdatedAt: null }),
);
// A stale base token.
await record(
await patch(session, {
pillars: ALL_PILLARS,
baseUpdatedAt: "2020-01-01T00:00:00.000Z",
}),
);
// The driver really produced the codes, so a contract naming none of
// them could not pass by the loop never running.
expect([...(emitted.get(422) ?? [])].sort()).toEqual([
"health_score_config.invalid",
"health_score_config.too_narrow",
"invalid_base_updated_at",
]);
expect([...(emitted.get(409) ?? [])]).toEqual([
"health_score_config_conflict",
]);
const responses = doc().paths?.[ROUTE]?.patch?.responses ?? {};
for (const [status, codes] of emitted) {
const described = responses[String(status)]?.description ?? "";
for (const code of codes) {
expect(
described,
`${status} description does not mention \`${code}\``,
).toContain(code);
}
}
});
});
@@ -0,0 +1,374 @@
/**
* `/api/auth/me/modules` — the per-account module switches, against the real
* database and through the real API kit.
*
* Pinned here:
* - the read resolves every toggleable module on for a fresh account, a
* stored `false` off, and the delegated modules from their real source
* (cycle from gender and the cycle profile, coach from the operator's
* switch);
* - the write merges the sent keys into the stored DISABLED allowlist key
* by key, drops junk a corrupted row carried, can turn a module back on,
* and audits which keys changed;
* - a core domain, an unknown key or a non-boolean value is a 422, a body
* that is not JSON a 400 with `modules.body.invalid_json`, and none of
* them touch the row;
* - a write evicts the account's cached score, and a refused write does
* not;
* - the optimistic-concurrency token is stripped before the strict parse,
* a stale one answers 409 `modules_conflict` and writes nothing, a
* malformed one 422 `invalid_base_updated_at`;
* - the write bucket allows 60 a minute per account.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET, PATCH } from "@/app/api/auth/me/modules/route";
import { caches } from "@/lib/cache/server-cache";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/modules";
type ModulesResponse = {
modules: Record<string, boolean>;
updatedAt?: string;
};
const NO_400 =
"the registry declares no 400 for PATCH /api/auth/me/modules although the handler answers one for a body that is not JSON";
const NO_409 =
"the registry declares no 409 for PATCH /api/auth/me/modules although the handler answers modules_conflict for a stale base token";
beforeEach(() => truncateAllTables(prisma));
async function storedPrefs(userId: string): Promise<unknown> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { modulePreferencesJson: true },
});
return row.modulePreferencesJson;
}
async function storedUpdatedAt(userId: string): Promise<Date> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { updatedAt: true },
});
return row.updatedAt;
}
async function patch(
auth: Awaited<ReturnType<typeof makeSession>>,
body: Record<string, unknown>,
contract?: { skip: string },
) {
return callRoute<ModulesResponse>(PATCH, {
method: "PATCH",
route: ROUTE,
body,
auth,
contract,
});
}
describe("GET /api/auth/me/modules", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("answers every toggleable module on for a fresh account, with its token", async () => {
const user = await makeUser({ gender: null });
const res = await callRoute<ModulesResponse>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data.modules.mood).toBe(true);
expect(res.data.modules.sleep).toBe(true);
expect(res.data.modules.insights).toBe(true);
// cycle derives from gender (unknown means off); coach follows the
// operator switch, on by default.
expect(res.data.modules.cycle).toBe(false);
expect(res.data.modules.coach).toBe(true);
expect(res.data.updatedAt).toBe(
(await storedUpdatedAt(user.id)).toISOString(),
);
});
it("answers a module off when the stored allowlist says false", async () => {
const user = await makeUser({ modulePreferencesJson: { glucose: false } });
const res = await callRoute<ModulesResponse>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.data.modules.glucose).toBe(false);
expect(res.data.modules.mood).toBe(true);
});
it("answers coach off while the operator has the coach switched off", async () => {
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", assistantCoachEnabled: false },
update: { assistantCoachEnabled: false },
});
const user = await makeUser({ disableCoach: false });
const res = await callRoute<ModulesResponse>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.data.modules.coach).toBe(false);
});
});
describe("PATCH /api/auth/me/modules", () => {
it("refuses an anonymous caller with 401 and stores nothing", async () => {
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { mood: false },
auth: anonymous,
});
expect(res.status).toBe(401);
expect(await prisma.auditLog.count()).toBe(0);
});
it("disables a module, stores the allowlist and audits the changed key", async () => {
const user = await makeUser();
const res = await patch(await makeSession(user), { mood: false });
expect(res.status).toBe(200);
expect(res.data.modules.mood).toBe(false);
expect(await storedPrefs(user.id)).toEqual({ mood: false });
expect(res.data.updatedAt).toBe(
(await storedUpdatedAt(user.id)).toISOString(),
);
const audit = await prisma.auditLog.findMany({
where: { userId: user.id, action: "user.modules.update" },
});
expect(audit).toHaveLength(1);
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
changed: ["mood"],
});
});
it("merges over the stored allowlist key by key, siblings intact", async () => {
const user = await makeUser({ modulePreferencesJson: { glucose: false } });
const res = await patch(await makeSession(user), { sleep: false });
expect(res.status).toBe(200);
expect(await storedPrefs(user.id)).toEqual({
glucose: false,
sleep: false,
});
expect(res.data.modules.glucose).toBe(false);
expect(res.data.modules.sleep).toBe(false);
});
it("turns a disabled module back on", async () => {
const user = await makeUser({ modulePreferencesJson: { glucose: false } });
const res = await patch(await makeSession(user), { glucose: true });
expect(res.status).toBe(200);
expect(await storedPrefs(user.id)).toEqual({ glucose: true });
expect(res.data.modules.glucose).toBe(true);
});
it("drops junk a corrupted stored row carried when it merges", async () => {
const user = await makeUser({
modulePreferencesJson: { mood: "yes", sleep: false },
});
const res = await patch(await makeSession(user), { labs: false });
expect(res.status).toBe(200);
expect(await storedPrefs(user.id)).toEqual({ sleep: false, labs: false });
});
it("refuses to disable a core domain with 422", async () => {
const user = await makeUser();
const session = await makeSession(user);
// medications is a toggleable module; weight, blood pressure and pulse
// are the core domains that stay refused.
for (const core of ["weight", "bloodPressure", "pulse"]) {
const res = await patch(session, { [core]: false });
expect(res.status).toBe(422);
expect(res.meta?.errorCode).toBe("modules.invalid");
}
expect(await storedPrefs(user.id)).toBeNull();
});
it("refuses an unknown key with 422", async () => {
const user = await makeUser();
const res = await patch(await makeSession(user), { notAModule: false });
expect(res.status).toBe(422);
expect(await storedPrefs(user.id)).toBeNull();
});
it("refuses a non-boolean value with 422", async () => {
const user = await makeUser();
const res = await patch(await makeSession(user), { mood: "false" });
expect(res.status).toBe(422);
expect(await storedPrefs(user.id)).toBeNull();
});
it("refuses a body that is not JSON with 400 and its error code", async () => {
const user = await makeUser();
// The kit serialises every body as JSON, so the unparseable body here is
// the empty one, sent with a JSON content type.
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
contract: { skip: NO_400 },
});
expect(res.status).toBe(400);
expect(res.error).toBe("Invalid JSON body");
expect(res.meta?.errorCode).toBe("modules.body.invalid_json");
expect(await storedPrefs(user.id)).toBeNull();
});
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
const user = await makeUser();
const session = await makeSession(user);
for (let i = 0; i < 60; i++) {
const ok = await patch(session, { mood: i % 2 === 0 });
expect(ok.status).toBe(200);
}
// The 60th write (i = 59) set mood off.
expect(await storedPrefs(user.id)).toEqual({ mood: false });
const refused = await patch(session, { mood: true });
expect(refused.status).toBe(429);
expect(await storedPrefs(user.id)).toEqual({ mood: false });
const neighbour = await patch(await makeSession(await makeUser()), {
mood: false,
});
expect(neighbour.status).toBe(200);
});
});
/**
* Four of the score's pillars come and go with a module toggle, so a toggle
* changes what the composite is made of; the cached score has to go.
*/
describe("PATCH /api/auth/me/modules evicts the cached score", () => {
it("evicts the account's score caches after a toggle lands", async () => {
const user = await makeUser();
const other = await makeUser();
caches.analytics.set(`${user.id}|score`, { score: 71 });
caches.insightsDerived.set(`${user.id}|derived`, { score: 71 });
caches.analytics.set(`${other.id}|score`, { score: 64 });
const res = await patch(await makeSession(user), { sleep: false });
expect(res.status).toBe(200);
expect(caches.analytics.get(`${user.id}|score`)).toBeNull();
expect(caches.insightsDerived.get(`${user.id}|derived`)).toBeNull();
expect(caches.analytics.get(`${other.id}|score`)).toEqual({ score: 64 });
});
it("leaves them when the write never landed", async () => {
const user = await makeUser();
caches.analytics.set(`${user.id}|score`, { score: 71 });
const res = await patch(
await makeSession(user),
{ sleep: false, baseUpdatedAt: "2020-01-01T00:00:00.000Z" },
{ skip: NO_409 },
);
expect(res.status).toBe(409);
expect(caches.analytics.get(`${user.id}|score`)).toEqual({ score: 71 });
});
});
/**
* The base token is stripped before the strict schema parse, so it never
* trips strict validation; a stale token 409s and writes nothing; a tokenless
* write stays unconditional.
*/
describe("PATCH /api/auth/me/modules optimistic concurrency", () => {
it("writes when the base token matches, and answers the advanced token", async () => {
const user = await makeUser();
const base = await storedUpdatedAt(user.id);
const res = await patch(await makeSession(user), {
mood: false,
baseUpdatedAt: base.toISOString(),
});
// 200, not 422: the token was removed before the strict parse.
expect(res.status).toBe(200);
expect(await storedPrefs(user.id)).toEqual({ mood: false });
const after = await storedUpdatedAt(user.id);
expect(after.getTime()).toBeGreaterThan(base.getTime());
expect(res.data.updatedAt).toBe(after.toISOString());
});
it("gives a second writer holding the old token 409 and keeps the first write", async () => {
const user = await makeUser();
const session = await makeSession(user);
const base = (await storedUpdatedAt(user.id)).toISOString();
const first = await patch(session, { mood: false, baseUpdatedAt: base });
expect(first.status).toBe(200);
const second = await patch(
session,
{ sleep: false, baseUpdatedAt: base },
{ skip: NO_409 },
);
expect(second.status).toBe(409);
expect(second.meta?.errorCode).toBe("modules_conflict");
expect(await storedPrefs(user.id)).toEqual({ mood: false });
});
it("writes unconditionally when the token is omitted", async () => {
const user = await makeUser({ modulePreferencesJson: { sleep: false } });
const session = await makeSession(user);
// Advance the row under the client: without a token nothing is compared.
await prisma.user.update({
where: { id: user.id },
data: { modulePreferencesJson: { sleep: false, labs: false } },
});
const res = await patch(session, { mood: false });
expect(res.status).toBe(200);
expect(await storedPrefs(user.id)).toEqual({
sleep: false,
labs: false,
mood: false,
});
});
it("still refuses an unknown key beside a valid token", async () => {
const user = await makeUser();
const base = (await storedUpdatedAt(user.id)).toISOString();
const res = await patch(await makeSession(user), {
notAModule: false,
baseUpdatedAt: base,
});
expect(res.status).toBe(422);
expect(await storedPrefs(user.id)).toBeNull();
expect((await storedUpdatedAt(user.id)).toISOString()).toBe(base);
});
it("refuses a malformed token with 422 without touching the row", async () => {
const user = await makeUser();
const base = (await storedUpdatedAt(user.id)).toISOString();
const res = await patch(await makeSession(user), {
mood: false,
baseUpdatedAt: "not-a-date",
});
expect(res.status).toBe(422);
expect(res.meta?.errorCode).toBe("invalid_base_updated_at");
expect(await storedPrefs(user.id)).toBeNull();
expect((await storedUpdatedAt(user.id)).toISOString()).toBe(base);
});
});
@@ -0,0 +1,405 @@
/**
* `/api/auth/me/notification-prefs` — the per-account notification
* preferences, against the real database and through the real API kit.
*
* Pinned here:
* - the read resolves a null row, a partial row and a drifted row (an
* unknown category) to the documented full shape, with the row's
* concurrency token;
* - the write deep-merges the sent categories over the stored ones, stores
* the full resolved shape, answers it with the advanced token, and audits
* previous, next and the categories the body named;
* - an empty body leaves the preferences as they were and still writes;
* - a body that is not JSON is 400 `notification-prefs.body.invalid_json`,
* an out-of-range or mistyped value is 422, and neither touches the row;
* - a write carrying a stale base token is 409 `notification_prefs_conflict`
* and does not revert a flag another client set; the same write without
* a token lands, last write wins; a malformed token is 422;
* - the write bucket allows 60 a minute per account.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET, PATCH } from "@/app/api/auth/me/notification-prefs/route";
import type { Prisma } from "@/generated/prisma/client";
import { anonymous, callRoute } from "../../kit/call-route";
import {
makeSession,
makeUser,
type SessionFixture,
} from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/notification-prefs";
const DEFAULTS = {
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: { clientManaged: false },
content: { detail: "generic" },
} as const;
type Prefs = {
medication: {
clientManaged: boolean;
deliveryDefault: string;
lowStockRunwayDays: number | null;
reorderLeadDays: number;
};
mood: { reminderHour: number };
content: { detail: string };
updatedAt?: string;
} & Record<string, unknown>;
beforeEach(() => truncateAllTables(prisma));
async function stored(userId: string): Promise<unknown> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { notificationPrefs: true },
});
return row.notificationPrefs;
}
async function storedUpdatedAt(userId: string): Promise<string> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { updatedAt: true },
});
return row.updatedAt.toISOString();
}
function userWithPrefs(prefs?: Prisma.InputJsonValue) {
return makeUser(prefs === undefined ? {} : { notificationPrefs: prefs });
}
function read(auth: SessionFixture) {
return callRoute<Prefs>(GET, { method: "GET", route: ROUTE, auth });
}
function patch(auth: SessionFixture, body: unknown) {
return callRoute<Prefs>(PATCH, {
method: "PATCH",
route: ROUTE,
body,
auth,
});
}
describe("GET /api/auth/me/notification-prefs", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("answers the documented defaults for an account with no stored prefs", async () => {
const user = await userWithPrefs();
const res = await read(await makeSession(user));
expect(res.status).toBe(200);
expect(res.data).toEqual({
...DEFAULTS,
updatedAt: await storedUpdatedAt(user.id),
});
});
it("answers a stored value resolved over the defaults", async () => {
const user = await userWithPrefs({ medication: { clientManaged: true } });
const res = await read(await makeSession(user));
expect(res.status).toBe(200);
expect(res.data).toEqual({
...DEFAULTS,
medication: { ...DEFAULTS.medication, clientManaged: true },
updatedAt: await storedUpdatedAt(user.id),
});
});
it("answers the defaults when the stored shape has drifted", async () => {
// An operator hand-edit or a future rename must not break the read.
const user = await userWithPrefs({ unknownCategory: { foo: "bar" } });
const res = await read(await makeSession(user));
expect(res.status).toBe(200);
expect(res.data).toEqual({
...DEFAULTS,
updatedAt: await storedUpdatedAt(user.id),
});
});
});
describe("PATCH /api/auth/me/notification-prefs", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { medication: { clientManaged: true } },
auth: anonymous,
});
expect(res.status).toBe(401);
expect(await prisma.auditLog.count()).toBe(0);
});
it("turns medication.clientManaged on, stores the full shape and audits the change", async () => {
const user = await userWithPrefs();
const res = await patch(await makeSession(user), {
medication: { clientManaged: true },
});
const next = {
...DEFAULTS,
medication: { ...DEFAULTS.medication, clientManaged: true },
};
expect(res.status).toBe(200);
expect(res.data).toEqual({
...next,
updatedAt: await storedUpdatedAt(user.id),
});
expect(await stored(user.id)).toEqual(next);
const audit = await prisma.auditLog.findMany({
where: { userId: user.id, action: "user.notification-prefs.update" },
});
expect(audit).toHaveLength(1);
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
previous: DEFAULTS,
next,
changed: ["medication"],
});
});
it("deep-merges over the stored categories without overwriting them", async () => {
const user = await userWithPrefs({
medication: { clientManaged: false, lowStockRunwayDays: 21 },
mood: { reminderHour: 7 },
});
const res = await patch(await makeSession(user), {
medication: { clientManaged: true },
});
expect(res.status).toBe(200);
expect(await stored(user.id)).toEqual({
...DEFAULTS,
medication: {
...DEFAULTS.medication,
clientManaged: true,
lowStockRunwayDays: 21,
},
mood: { reminderHour: 7 },
});
});
it("leaves the preferences as they were on an empty body and still writes", async () => {
const user = await userWithPrefs({ medication: { clientManaged: true } });
const res = await patch(await makeSession(user), {});
const same = {
...DEFAULTS,
medication: { ...DEFAULTS.medication, clientManaged: true },
};
expect(res.status).toBe(200);
expect(res.data).toEqual({
...same,
updatedAt: await storedUpdatedAt(user.id),
});
// The resolved full shape replaces the partial row.
expect(await stored(user.id)).toEqual(same);
const audit = await prisma.auditLog.findMany({
where: { userId: user.id, action: "user.notification-prefs.update" },
});
expect(JSON.parse(audit[0]?.details ?? "null")).toMatchObject({
changed: [],
});
});
it("stores a custom mood.reminderHour", async () => {
const user = await userWithPrefs();
const res = await patch(await makeSession(user), {
mood: { reminderHour: 9 },
});
expect(res.status).toBe(200);
expect(res.data.mood).toEqual({ reminderHour: 9 });
expect(await stored(user.id)).toEqual({
...DEFAULTS,
mood: { reminderHour: 9 },
});
});
it("stores content.detail and keeps the siblings", async () => {
const user = await userWithPrefs({ mood: { reminderHour: 7 } });
const res = await patch(await makeSession(user), {
content: { detail: "detailed" },
});
expect(res.status).toBe(200);
expect(res.data.content).toEqual({ detail: "detailed" });
expect(res.data.mood).toEqual({ reminderHour: 7 });
expect(await stored(user.id)).toEqual({
...DEFAULTS,
mood: { reminderHour: 7 },
content: { detail: "detailed" },
});
});
it("stores a custom lowStockRunwayDays", async () => {
const user = await userWithPrefs();
const res = await patch(await makeSession(user), {
medication: { lowStockRunwayDays: 14 },
});
expect(res.status).toBe(200);
expect(res.data.medication.lowStockRunwayDays).toBe(14);
expect(await stored(user.id)).toMatchObject({
medication: { ...DEFAULTS.medication, lowStockRunwayDays: 14 },
});
});
it("stores the explicit null that turns the low-stock alert off", async () => {
const user = await userWithPrefs({
medication: { lowStockRunwayDays: 14 },
});
const session = await makeSession(user);
const res = await patch(session, {
medication: { lowStockRunwayDays: null },
});
expect(res.status).toBe(200);
expect(res.data.medication.lowStockRunwayDays).toBeNull();
expect(await stored(user.id)).toMatchObject({
medication: { lowStockRunwayDays: null },
});
// And the null survives the read, rather than falling back to 7.
expect((await read(session)).data.medication.lowStockRunwayDays).toBeNull();
});
it.each<[string, Record<string, unknown>]>([
["a non-boolean clientManaged", { medication: { clientManaged: "yes" } }],
["an unknown content.detail", { content: { detail: "everything" } }],
["a mood.reminderHour of 24", { mood: { reminderHour: 24 } }],
["a lowStockRunwayDays of 0", { medication: { lowStockRunwayDays: 0 } }],
["a lowStockRunwayDays of 61", { medication: { lowStockRunwayDays: 61 } }],
])("refuses %s with 422 and stores nothing", async (_, body) => {
const user = await userWithPrefs();
const res = await patch(await makeSession(user), body);
expect(res.status).toBe(422);
expect(await stored(user.id)).toBeNull();
});
it("refuses a body that is not JSON with 400 and its error code", async () => {
const user = await userWithPrefs();
// The kit serialises every body as JSON, so the unparseable body here is
// the empty one, sent with a JSON content type.
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
});
expect(res.status).toBe(400);
expect(res.error).toBe("Invalid JSON body");
expect(res.meta?.errorCode).toBe("notification-prefs.body.invalid_json");
expect(await stored(user.id)).toBeNull();
});
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
const user = await userWithPrefs();
const session = await makeSession(user);
for (let hour = 0; hour < 60; hour++) {
const ok = await patch(session, { mood: { reminderHour: hour % 24 } });
expect(ok.status).toBe(200);
}
// The 60th write set 59 % 24 = 11.
const refused = await patch(session, { mood: { reminderHour: 3 } });
expect(refused.status).toBe(429);
expect(await stored(user.id)).toMatchObject({ mood: { reminderHour: 11 } });
});
});
/**
* A write carrying the `baseUpdatedAt` it was based on lands only while the
* stored row still carries that token. The case it exists for: a web write
* of one category, based on a read taken before the native client set
* `medication.clientManaged`, used to revert the flag (double reminders).
*/
describe("PATCH /api/auth/me/notification-prefs optimistic concurrency", () => {
it("writes on a matching token and answers the advanced one", async () => {
const user = await userWithPrefs();
const session = await makeSession(user);
const base = (await read(session)).data.updatedAt;
const res = await patch(session, {
medication: { clientManaged: true },
baseUpdatedAt: base,
});
expect(res.status).toBe(200);
const after = await storedUpdatedAt(user.id);
expect(after).not.toBe(base);
expect(res.data.updatedAt).toBe(after);
expect(await stored(user.id)).toMatchObject({
medication: { clientManaged: true },
});
});
it("refuses a stale web write with 409 and keeps the flag the native client set", async () => {
const user = await userWithPrefs();
const web = await makeSession(user);
const staleBase = (await read(web)).data.updatedAt;
const native = await patch(await makeSession(user), {
medication: { clientManaged: true },
});
expect(native.status).toBe(200);
const res = await patch(web, {
mood: { reminderHour: 9 },
baseUpdatedAt: staleBase,
});
expect(res.status).toBe(409);
expect(res.data).toBeNull();
expect(res.meta?.errorCode).toBe("notification_prefs_conflict");
expect(await stored(user.id)).toEqual({
...DEFAULTS,
medication: { ...DEFAULTS.medication, clientManaged: true },
});
});
it("lets the same write land without a token: old clients keep last write wins", async () => {
// Deliberate: a tokenless client keeps the pre-guard race. Its write of
// one category merges over the row, so here it does not lose the flag.
const user = await userWithPrefs({ medication: { clientManaged: true } });
const res = await patch(await makeSession(user), {
mood: { reminderHour: 9 },
});
expect(res.status).toBe(200);
expect(await stored(user.id)).toEqual({
...DEFAULTS,
medication: { ...DEFAULTS.medication, clientManaged: true },
mood: { reminderHour: 9 },
});
});
it("refuses a malformed token with 422 without touching the row", async () => {
const user = await userWithPrefs();
const before = await storedUpdatedAt(user.id);
const res = await patch(await makeSession(user), {
medication: { clientManaged: true },
baseUpdatedAt: "not-a-date",
});
expect(res.status).toBe(422);
expect(res.meta?.errorCode).toBe("invalid_base_updated_at");
expect(await stored(user.id)).toBeNull();
expect(await storedUpdatedAt(user.id)).toBe(before);
});
});
@@ -0,0 +1,152 @@
/**
* `/api/auth/me/source-priority` — the per-metric source ladders, against the
* real database and through the real API kit.
*
* Pinned here:
* - the read fills every key the analytics layer needs from the defaults
* when nothing is stored, and carries the sources the account holds data
* from beside the ladders;
* - the PUT stores the partial shape as sent (missing keys keep reading as
* defaults), audits previous and next, and refuses a malformed shape
* without touching the row.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET, PUT } from "@/app/api/auth/me/source-priority/route";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeMeasurement, makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/source-priority";
type SourcePriority = Record<string, unknown> & {
weight: string[];
inUse: string[];
};
beforeEach(() => truncateAllTables(prisma));
async function storedLadder(userId: string) {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { sourcePriorityJson: true },
});
return row.sourcePriorityJson;
}
describe("GET /api/auth/me/source-priority", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
});
it("answers the defaulted shape when nothing is stored", async () => {
const user = await makeUser();
const res = await callRoute<SourcePriority>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toHaveProperty("weight");
expect(res.data).toHaveProperty("metricPriority");
expect(res.data).toHaveProperty("deviceTypePriority");
expect(res.data.inUse).toEqual([]);
});
it("carries the sources the account holds data from beside the ladders", async () => {
const user = await makeUser();
const other = await makeUser();
await makeMeasurement(user, { source: "WITHINGS" });
await makeMeasurement(user, { source: "MANUAL" });
await makeMeasurement(other, { source: "APPLE_HEALTH" });
const res = await callRoute<SourcePriority>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect([...res.data.inUse].sort()).toEqual(["MANUAL", "WITHINGS"]);
});
});
describe("PUT /api/auth/me/source-priority", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] },
auth: anonymous,
});
expect(res.status).toBe(401);
});
it("stores the partial shape as sent and reads it back over the defaults", async () => {
const user = await makeUser();
const session = await makeSession(user);
const body = { weight: ["MANUAL", "APPLE_HEALTH", "WITHINGS"] };
const res = await callRoute<SourcePriority>(PUT, {
method: "PUT",
route: ROUTE,
body,
auth: session,
});
expect(res.status).toBe(200);
// The resolved ladder leads with the chosen order; the resolver appends
// the sources the body did not rank.
expect(res.data.weight.slice(0, 3)).toEqual(body.weight);
expect(await storedLadder(user.id)).toEqual(body);
const read = await callRoute<SourcePriority>(GET, {
method: "GET",
route: ROUTE,
auth: session,
});
expect(read.data.weight).toEqual(res.data.weight);
expect(read.data).toHaveProperty("metricPriority");
});
it("refuses an invalid shape with 422 and stores nothing", async () => {
const user = await makeUser();
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { weight: "not-an-array" },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(res.meta?.errorCode).toBe("source-priority.body.invalid_shape");
expect(await storedLadder(user.id)).toBeNull();
});
it("audits the previous and the new shape", async () => {
const previousShape = { weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] };
const user = await makeUser({ sourcePriorityJson: previousShape });
const body = { weight: ["APPLE_HEALTH", "WITHINGS", "MANUAL"] };
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
const audit = await prisma.auditLog.findMany({
where: { userId: user.id, action: "user.source-priority.update" },
});
expect(audit).toHaveLength(1);
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
previous: previousShape,
next: body,
});
});
});
@@ -0,0 +1,165 @@
/**
* `PUT /api/auth/me/timezone` — the account timezone, against the real
* database and through the real API kit.
*
* Pinned here:
* - any zone the engine's `Intl` knows is stored, trimmed, and audited with
* the previous and next zone; an unknown, empty or missing zone is 422 and
* leaves the column alone;
* - the resolver's per-user cache follows the write;
* - a change re-buckets the supplement nutrient days through a recompute
* job on the real queue, and saving the same zone again queues none.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { PgBoss } from "pg-boss";
import { PUT } from "@/app/api/auth/me/timezone/route";
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
import { SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE } from "@/lib/medications/ingredients/recompute-job";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/timezone";
let boss: PgBoss;
beforeAll(async () => {
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
await boss.start();
await boss.createQueue(SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE);
setGlobalBoss(boss);
}, 120_000);
afterAll(async () => {
setGlobalBoss(null);
await boss?.stop({ graceful: false });
});
beforeEach(() => truncateAllTables(prisma));
async function storedTimezone(userId: string): Promise<string> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { timezone: true },
});
return row.timezone;
}
/** The recompute is awaited by the route, so its job is on the queue on return. */
async function recomputeJobs(userId: string): Promise<number> {
const rows = await prisma.$queryRaw<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM pgboss.job
WHERE name = ${SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE}
AND singleton_key = ${`supplement-nutrient-recompute|${userId}`}
`;
return rows[0]?.n ?? 0;
}
describe("PUT /api/auth/me/timezone", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { timezone: "Europe/Berlin" },
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("stores a valid IANA zone, answers it and audits previous and next", async () => {
const user = await makeUser({ timezone: "Europe/Berlin" });
const res = await callRoute<{ timezone: string }>(PUT, {
method: "PUT",
route: ROUTE,
body: { timezone: "Pacific/Auckland" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ timezone: "Pacific/Auckland" });
expect(await storedTimezone(user.id)).toBe("Pacific/Auckland");
const audit = await prisma.auditLog.findMany({
where: { userId: user.id, action: "user.timezone.update" },
});
expect(audit).toHaveLength(1);
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
previous: "Europe/Berlin",
next: "Pacific/Auckland",
});
});
it.each([
["an unknown zone", { timezone: "Mars/Olympus" }],
["an empty zone", { timezone: "" }],
["a missing field", {}],
])("refuses %s with 422 and leaves the column alone", async (_, body) => {
const user = await makeUser({ timezone: "Europe/Berlin" });
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body,
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(await storedTimezone(user.id)).toBe("Europe/Berlin");
});
it("trims whitespace before validating and stores the trimmed zone", async () => {
const user = await makeUser({ timezone: "Europe/Berlin" });
const res = await callRoute<{ timezone: string }>(PUT, {
method: "PUT",
route: ROUTE,
body: { timezone: " Asia/Tokyo " },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ timezone: "Asia/Tokyo" });
expect(await storedTimezone(user.id)).toBe("Asia/Tokyo");
});
it("drops the resolver's cached zone, so the next resolution reads the new one", async () => {
const user = await makeUser({ timezone: "Europe/Berlin" });
expect(await resolveUserTimezone(user.id)).toBe("Europe/Berlin");
await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { timezone: "America/New_York" },
auth: await makeSession(user),
});
expect(await resolveUserTimezone(user.id)).toBe("America/New_York");
});
// The supplement nutrient rows' day keys were minted under the old zone;
// without the recompute nothing re-buckets them.
it("queues the nutrient recompute when the zone actually changes", async () => {
const user = await makeUser({ timezone: "Europe/Berlin" });
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { timezone: "America/New_York" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(await recomputeJobs(user.id)).toBe(1);
});
it("queues no recompute when the zone is unchanged", async () => {
const user = await makeUser({ timezone: "Europe/Berlin" });
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { timezone: "Europe/Berlin" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(await recomputeJobs(user.id)).toBe(0);
});
});
@@ -0,0 +1,290 @@
/**
* `/api/auth/me/unit-preference` — the metric/imperial display preference,
* against the real database and through the real API kit.
*
* Pinned here:
* - the read resolves anything other than `imperial` (a null on an account
* that predates the column, or a stray value) to `metric`;
* - the write stores the value, audits previous and next, and refuses an
* unknown value, malformed JSON, an oversized body and a non-JSON content
* type without touching the row;
* - a write that changes the unit re-warms the stored texts that quote it
* (here: the cached daily briefing, a force-warm job on the real queue),
* and saving the same unit again does not;
* - the write bucket allows 60 a minute per account.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { PgBoss } from "pg-boss";
import { GET, PATCH } from "@/app/api/auth/me/unit-preference/route";
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
import { INSIGHT_PREGENERATE_QUEUE } from "@/lib/jobs/insight-pregenerate-shared";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/unit-preference";
type UnitPreference = { unitPreference: "metric" | "imperial" };
let boss: PgBoss;
beforeAll(async () => {
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
await boss.start();
await boss.createQueue(INSIGHT_PREGENERATE_QUEUE);
setGlobalBoss(boss);
}, 120_000);
afterAll(async () => {
setGlobalBoss(null);
await boss?.stop({ graceful: false });
});
beforeEach(() => truncateAllTables(prisma));
async function forceWarmJobs(userId: string): Promise<number> {
const rows = await prisma.$queryRaw<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM pgboss.job
WHERE name = ${INSIGHT_PREGENERATE_QUEUE}
AND singleton_key = ${`force:${userId}`}
`;
return rows[0]?.n ?? 0;
}
/** The re-warm is fire-and-forget; wait for its job to land on the queue. */
async function waitForForceWarm(userId: string): Promise<void> {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
if ((await forceWarmJobs(userId)) > 0) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`no force-warm job for ${userId}`);
}
async function storedPreference(userId: string): Promise<string | null> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { unitPreference: true },
});
return row.unitPreference;
}
describe("GET /api/auth/me/unit-preference", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("answers metric for an account whose column is null", async () => {
const user = await makeUser({ unitPreference: null });
const res = await callRoute<UnitPreference>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ unitPreference: "metric" });
});
it("answers metric for a stored value that is not imperial", async () => {
const user = await makeUser({ unitPreference: "furlongs" });
const res = await callRoute<UnitPreference>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ unitPreference: "metric" });
});
it("answers imperial when it is stored", async () => {
const user = await makeUser({ unitPreference: "imperial" });
const res = await callRoute<UnitPreference>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.data).toEqual({ unitPreference: "imperial" });
});
});
describe("PATCH /api/auth/me/unit-preference", () => {
it("stores imperial, answers the next state and audits previous and next", async () => {
const user = await makeUser({ unitPreference: null });
const res = await callRoute<UnitPreference>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { unitPreference: "imperial" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ unitPreference: "imperial" });
expect(await storedPreference(user.id)).toBe("imperial");
const audit = await prisma.auditLog.findMany({
where: { userId: user.id, action: "user.unit-preference.update" },
});
expect(audit).toHaveLength(1);
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
previous: "metric",
next: "imperial",
});
const read = await callRoute<UnitPreference>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(read.data).toEqual({ unitPreference: "imperial" });
});
it("refuses an unknown value with 422 and stores nothing", async () => {
const user = await makeUser({ unitPreference: "metric" });
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { unitPreference: "furlongs" },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(await storedPreference(user.id)).toBe("metric");
});
// The three cases below pin the capped `safeJson` read: malformed JSON is
// 400 like every sibling scalar, an oversized body is 413 even when its
// value is valid, and a non-JSON content type is 415.
it("refuses a body that is not JSON with 400 and stores nothing", async () => {
const user = await makeUser({ unitPreference: "metric" });
// The kit serialises every body as JSON, so the unparseable body here is
// the empty one, sent with a JSON content type.
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
contract: {
skip: "the registry declares no 400 for this PATCH although the description names it",
},
});
expect(res.status).toBe(400);
expect(await storedPreference(user.id)).toBe("metric");
});
it("refuses a body over the 1 KB cap with 413 even when the value is valid", async () => {
const user = await makeUser({ unitPreference: "metric" });
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { unitPreference: "imperial", padding: "x".repeat(2048) },
auth: await makeSession(user),
});
expect(res.status).toBe(413);
expect(await storedPreference(user.id)).toBe("metric");
});
it("refuses a non-JSON content type with 415", async () => {
const user = await makeUser({ unitPreference: "metric" });
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: "unitPreference=imperial",
headers: { "content-type": "application/x-www-form-urlencoded" },
auth: await makeSession(user),
contract: {
skip: "the registry declares no 415 for this PATCH although the description names it",
},
});
expect(res.status).toBe(415);
expect(await storedPreference(user.id)).toBe("metric");
});
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
const user = await makeUser();
const other = await makeUser();
const session = await makeSession(user);
for (let i = 0; i < 60; i++) {
const ok = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { unitPreference: i % 2 === 0 ? "imperial" : "metric" },
auth: session,
});
expect(ok.status).toBe(200);
}
const refused = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { unitPreference: "imperial" },
auth: session,
});
expect(refused.status).toBe(429);
// The 60th write set metric; the refused one did not land.
expect(await storedPreference(user.id)).toBe("metric");
const neighbour = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { unitPreference: "imperial" },
auth: await makeSession(other),
});
expect(neighbour.status).toBe(200);
});
});
describe("PATCH re-warms the texts that quote the unit", () => {
it("queues a briefing re-warm when the unit actually changes", async () => {
const user = await makeUser({
unitPreference: null,
insightsCachedAt: new Date(),
});
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { unitPreference: "imperial" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
await waitForForceWarm(user.id);
});
it("queues nothing when the same unit is saved again", async () => {
const unchanged = await makeUser({
unitPreference: "imperial",
insightsCachedAt: new Date(),
});
const changed = await makeUser({
unitPreference: "metric",
insightsCachedAt: new Date(),
});
const same = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { unitPreference: "imperial" },
auth: await makeSession(unchanged),
});
expect(same.status).toBe(200);
// A second account that does change its unit, sent after the first: once
// its job is on the queue, a re-warm the first write had started would
// have landed as well.
await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { unitPreference: "imperial" },
auth: await makeSession(changed),
});
await waitForForceWarm(changed.id);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(await forceWarmJobs(unchanged.id)).toBe(0);
});
});
@@ -0,0 +1,97 @@
/**
* `/api/auth/me/workout-routes` — the opt-in for keeping workout GPS tracks,
* against the real database and through the real API kit.
*
* Off by default; the PATCH is a hard-set that stores the flag, audits
* previous and next, and refuses a malformed body or an anonymous caller
* without touching the row.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET, PATCH } from "@/app/api/auth/me/workout-routes/route";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/workout-routes";
type WorkoutRoutes = { storeWorkoutRoutes: boolean };
beforeEach(() => truncateAllTables(prisma));
async function storedFlag(userId: string): Promise<boolean> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { storeWorkoutRoutes: true },
});
return row.storeWorkoutRoutes;
}
describe("/api/auth/me/workout-routes", () => {
it("reads off for an account that never chose", async () => {
const user = await makeUser();
const res = await callRoute<WorkoutRoutes>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ storeWorkoutRoutes: false });
});
it("stores the flag, answers it, and audits the change", async () => {
const user = await makeUser();
const session = await makeSession(user);
const res = await callRoute<WorkoutRoutes>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { storeWorkoutRoutes: true },
auth: session,
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ storeWorkoutRoutes: true });
expect(await storedFlag(user.id)).toBe(true);
const audit = await prisma.auditLog.findMany({
where: { userId: user.id, action: "user.storeWorkoutRoutes.update" },
});
expect(audit).toHaveLength(1);
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
previous: false,
next: true,
});
const read = await callRoute<WorkoutRoutes>(GET, {
method: "GET",
route: ROUTE,
auth: session,
});
expect(read.data).toEqual({ storeWorkoutRoutes: true });
});
it("refuses a malformed body with 422 and writes nothing", async () => {
const user = await makeUser();
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { storeWorkoutRoutes: "yes" },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(await storedFlag(user.id)).toBe(false);
expect(await prisma.auditLog.count({ where: { userId: user.id } })).toBe(0);
});
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { storeWorkoutRoutes: true },
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
});