mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user