From 0716f7a2fb2be43f80fe0db796c4c984f0d8f6c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 13 Aug 2026 01:39:47 +0200 Subject: [PATCH] Pin the declared diabetes flag's route contract with tests GET/PATCH /api/auth/me/diabetes carried no test; the web toggle on the Targets page and the iOS client both ride it. Cover the auth gate, the resolved-state echo both ways, the field-by-field write, the audit row, the 422 shape rejections, and the 429 path. --- .../auth/me/diabetes/__tests__/route.test.ts | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 src/app/api/auth/me/diabetes/__tests__/route.test.ts diff --git a/src/app/api/auth/me/diabetes/__tests__/route.test.ts b/src/app/api/auth/me/diabetes/__tests__/route.test.ts new file mode 100644 index 000000000..5919ff386 --- /dev/null +++ b/src/app/api/auth/me/diabetes/__tests__/route.test.ts @@ -0,0 +1,205 @@ +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, + 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, + 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(); + }); +});