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();
});
});