From c0c0fdb421120bb9060f3b1d1295eefec302847d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:31:59 +0200 Subject: [PATCH] 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. --- .../auth/me/ai-usage/__tests__/route.test.ts | 206 ----- .../me/coach-prefs/__tests__/route.test.ts | 221 ----- .../auth/me/diabetes/__tests__/route.test.ts | 207 ----- .../me/disable-coach/__tests__/route.test.ts | 270 ------- .../__tests__/unknown-key-strict.test.ts | 120 --- .../__tests__/route.test.ts | 197 ----- .../me/glucose-unit/__tests__/route.test.ts | 247 ------ .../__tests__/route.test.ts | 572 ------------- .../auth/me/modules/__tests__/route.test.ts | 463 ----------- .../__tests__/route.test.ts | 754 ------------------ .../source-priority/__tests__/route.test.ts | 153 ---- .../auth/me/timezone/__tests__/route.test.ts | 146 ---- .../unit-preference/__tests__/route.test.ts | 234 ------ .../me/workout-routes/__tests__/route.test.ts | 103 --- .../routes/auth-me-ai-usage.test.ts | 236 ++++++ .../routes/auth-me-coach-prefs.test.ts | 244 ++++++ .../routes/auth-me-diabetes.test.ts | 175 ++++ .../routes/auth-me-disable-coach.test.ts | 276 +++++++ .../auth-me-documents-auto-ai-read.test.ts | 354 ++++++++ .../routes/auth-me-glucose-unit.test.ts | 274 +++++++ .../auth-me-health-score-config.test.ts | 483 +++++++++++ .../routes/auth-me-modules.test.ts | 374 +++++++++ .../routes/auth-me-notification-prefs.test.ts | 405 ++++++++++ .../routes/auth-me-source-priority.test.ts | 152 ++++ .../routes/auth-me-timezone.test.ts | 165 ++++ .../routes/auth-me-unit-preference.test.ts | 290 +++++++ .../routes/auth-me-workout-routes.test.ts | 97 +++ 27 files changed, 3525 insertions(+), 3893 deletions(-) delete mode 100644 src/app/api/auth/me/ai-usage/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/coach-prefs/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/diabetes/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/disable-coach/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts delete mode 100644 src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/glucose-unit/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/health-score-config/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/modules/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/notification-prefs/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/source-priority/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/timezone/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/unit-preference/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/workout-routes/__tests__/route.test.ts create mode 100644 tests/integration/routes/auth-me-ai-usage.test.ts create mode 100644 tests/integration/routes/auth-me-coach-prefs.test.ts create mode 100644 tests/integration/routes/auth-me-diabetes.test.ts create mode 100644 tests/integration/routes/auth-me-disable-coach.test.ts create mode 100644 tests/integration/routes/auth-me-documents-auto-ai-read.test.ts create mode 100644 tests/integration/routes/auth-me-glucose-unit.test.ts create mode 100644 tests/integration/routes/auth-me-health-score-config.test.ts create mode 100644 tests/integration/routes/auth-me-modules.test.ts create mode 100644 tests/integration/routes/auth-me-notification-prefs.test.ts create mode 100644 tests/integration/routes/auth-me-source-priority.test.ts create mode 100644 tests/integration/routes/auth-me-timezone.test.ts create mode 100644 tests/integration/routes/auth-me-unit-preference.test.ts create mode 100644 tests/integration/routes/auth-me-workout-routes.test.ts diff --git a/src/app/api/auth/me/ai-usage/__tests__/route.test.ts b/src/app/api/auth/me/ai-usage/__tests__/route.test.ts deleted file mode 100644 index 71eb1b3af..000000000 --- a/src/app/api/auth/me/ai-usage/__tests__/route.test.ts +++ /dev/null @@ -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, - 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; - -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); - }); -}); diff --git a/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts b/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts deleted file mode 100644 index e39d0ab96..000000000 --- a/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts +++ /dev/null @@ -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(); - 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)(); - 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)(); - 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)( - 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)( - 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)( - 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)( - 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)( - 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)(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)( - mkPut({ reasoning: value }), - ); - expect(res.status).toBe(422); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/auth/me/diabetes/__tests__/route.test.ts b/src/app/api/auth/me/diabetes/__tests__/route.test.ts deleted file mode 100644 index 0cee31013..000000000 --- a/src/app/api/auth/me/diabetes/__tests__/route.test.ts +++ /dev/null @@ -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)(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)(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)(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)( - 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)( - 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)( - 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)( - 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)( - 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)( - mkPatch({ hasDiabetes: true }), - ); - expect(res.status).toBe(429); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts deleted file mode 100644 index 0726fb835..000000000 --- a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts +++ /dev/null @@ -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)( - 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)( - 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)( - 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)( - 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)( - 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)( - 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)( - 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)( - 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)( - 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)(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)( - 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)( - mkPatch({ disableCoach: true }), - ); - expect(res.status).toBe(429); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts b/src/app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts deleted file mode 100644 index 037b72c43..000000000 --- a/src/app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts +++ /dev/null @@ -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; - -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, "": 1 }), - ); - expect(res.status).toBe(422); - const raw = await res.text(); - expect(raw).not.toContain("": 1 }, + auth: await makeSession(user), + }); + expect(res.status).toBe(422); + const raw = JSON.stringify(res.body); + expect(raw).not.toContain("