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 new file mode 100644 index 000000000..0eec03dd1 --- /dev/null +++ b/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts @@ -0,0 +1,157 @@ +/** + * v1.32.22 (M4) — `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 the prefs plus the optimistic-concurrency token", async () => { + 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).toBe("2026-07-24T10:00:00.000Z"); + }); +}); + +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(); + }); +}); diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts index d64f33bc5..3bc33635d 100644 --- a/src/app/api/auth/me/modules/__tests__/route.test.ts +++ b/src/app/api/auth/me/modules/__tests__/route.test.ts @@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn(), update: vi.fn(), + updateMany: vi.fn(), }, cycleProfile: { findUnique: vi.fn(), @@ -198,6 +199,7 @@ describe("PATCH /api/auth/me/modules", () => { expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, + select: { updatedAt: true }, data: { modulePreferencesJson: { mood: false } }, }); expect(auditLog).toHaveBeenCalledWith( @@ -220,6 +222,7 @@ describe("PATCH /api/auth/me/modules", () => { expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, + select: { updatedAt: true }, data: { modulePreferencesJson: { glucose: false, sleep: false } }, }); }); @@ -234,6 +237,7 @@ describe("PATCH /api/auth/me/modules", () => { expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, + select: { updatedAt: true }, data: { modulePreferencesJson: { glucose: true } }, }); }); @@ -310,7 +314,100 @@ describe("PATCH /api/auth/me/modules", () => { expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, + select: { updatedAt: true }, data: { modulePreferencesJson: { sleep: false, labs: false } }, }); }); }); + +/** + * v1.32.22 (M3) — 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)( + 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)( + 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)( + 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)( + 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)( + 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)( + 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(); + }); +}); diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index c67287ba2..088cecd53 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn(), update: vi.fn(), + updateMany: vi.fn(), }, }, toJson: (v: T) => v, @@ -234,10 +235,13 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + // v1.32.22 (M1) — 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: { @@ -365,6 +369,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, + select: { updatedAt: true }, data: { notificationPrefs: { medication: { @@ -427,10 +432,13 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + // v1.32.22 (M1) — 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: { @@ -476,6 +484,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, + select: { updatedAt: true }, data: { notificationPrefs: { medication: { @@ -531,6 +540,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, + select: { updatedAt: true }, data: { notificationPrefs: expect.objectContaining({ medication: { @@ -588,3 +598,113 @@ describe("PATCH /api/auth/me/notification-prefs", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); }); + +/** + * v1.32.22 (M1) — 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)( + 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)( + 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)( + 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)( + 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(); + }); +}); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts index ff22da059..ad3f124ce 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts @@ -9,16 +9,23 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -vi.mock("@/lib/db", () => ({ - prisma: { +vi.mock("@/lib/db", () => { + // v1.32.22 (M5) — the PATCH / DELETE now run inside `prisma.$transaction` + // that takes the per-medication advisory lock. The mock transaction runs the + // callback with the base client as the tx, so the existing model mocks apply + // inside the transaction and `$queryRaw` (the advisory lock) is observable. + const prisma: Record = { medication: { findUnique: vi.fn() }, medicationInventoryItem: { findUnique: vi.fn(), update: vi.fn(), delete: vi.fn(), }, - }, -})); + $queryRaw: vi.fn(), + $transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(prisma)), + }; + return { prisma }; +}); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -52,11 +59,16 @@ vi.mock("next/headers", () => ({ })), })); -import { PATCH } from "../route"; +import { PATCH, DELETE } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; +const prismaTx = prisma as unknown as { + $queryRaw: ReturnType; + $transaction: ReturnType; +}; + const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { id: "user-1", username: "tester", role: "USER" as const }, @@ -76,6 +88,11 @@ const ROUTE_CTX = { beforeEach(() => { vi.resetAllMocks(); + // `resetAllMocks` clears the transaction implementation — re-establish it so + // the callback still runs with the base client as the tx. + prismaTx.$transaction.mockImplementation((fn: (tx: unknown) => unknown) => + fn(prisma), + ); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({ @@ -244,3 +261,95 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — carton labelling", expect("doseStrength" in arg.data).toBe(false); }); }); + +/** + * v1.32.22 (M5) — the stock-correction PATCH and the DELETE route their + * read-compose-write through the SAME per-medication advisory lock that + * `consumeForIntake` takes, so an absolute stock write can no longer clobber a + * concurrent dose decrement. These pin the routing: the write runs inside a + * transaction that takes the advisory lock BEFORE it reads + writes. + */ +describe("PATCH / DELETE inventory — advisory-lock serialisation (M5)", () => { + beforeEach(() => { + vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({ + id: "i1", + medicationId: "m1", + userId: "user-1", + firstUseAt: null, + state: "IN_USE", + unitsTotal: 4, + unitsRemaining: 3, + printedExpiry: null, + notes: null, + } as never); + vi.mocked(prisma.medicationInventoryItem.update).mockImplementation( + (async (args: { data: Record }) => ({ + id: "i1", + ...args.data, + })) as never, + ); + vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({ + id: "i1", + } as never); + }); + + it("takes the medication advisory lock inside a transaction before the write", async () => { + const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX); + expect(res.status).toBe(200); + + // One transaction, one advisory-lock query. + expect(prismaTx.$transaction).toHaveBeenCalledTimes(1); + expect(prismaTx.$queryRaw).toHaveBeenCalledTimes(1); + + // The lock SQL is the canonical `pg_advisory_xact_lock` — proves the route + // shares the intake hook's key rather than re-deriving one. + const sql = prismaTx.$queryRaw.mock.calls[0]?.[0] as { + strings?: string[]; + }; + expect((sql.strings ?? []).join("")).toContain("pg_advisory_xact_lock"); + + // Lock is acquired BEFORE the row update (serialises against a concurrent + // consume decrement). + const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0]; + const updateOrder = vi.mocked(prisma.medicationInventoryItem.update).mock + .invocationCallOrder[0]; + expect(lockOrder).toBeLessThan(updateOrder); + }); + + it("re-reads the row INSIDE the locked transaction", async () => { + await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX); + // The ownership re-read runs after the lock is taken. + const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0]; + const readOrder = vi.mocked(prisma.medicationInventoryItem.findUnique).mock + .invocationCallOrder[0]; + expect(lockOrder).toBeLessThan(readOrder); + }); + + it("DELETE takes the same lock before deleting", async () => { + const res = await DELETE(patchReq({}), ROUTE_CTX); + expect(res.status).toBe(200); + expect(prismaTx.$queryRaw).toHaveBeenCalledTimes(1); + const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0]; + const deleteOrder = vi.mocked(prisma.medicationInventoryItem.delete).mock + .invocationCallOrder[0]; + expect(lockOrder).toBeLessThan(deleteOrder); + }); + + it("still 404s an item owned by another user (checked inside the lock)", async () => { + vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({ + id: "i1", + medicationId: "m1", + userId: "someone-else", + state: "IN_USE", + unitsTotal: 4, + unitsRemaining: 3, + printedExpiry: null, + firstUseAt: null, + notes: null, + } as never); + + const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX); + expect(res.status).toBe(404); + expect(prisma.medicationInventoryItem.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts index ecbe8bfa3..bb4926240 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -vi.mock("@/lib/db", () => ({ - prisma: { +vi.mock("@/lib/db", () => { + // v1.32.22 (M5) — the [itemId] PATCH / DELETE now run inside a transaction + // that takes the per-medication advisory lock. The mock transaction runs the + // callback with the base client as tx, and `$queryRaw` stands in for the + // advisory-lock query. + const prisma: Record = { medication: { findUnique: vi.fn() }, medicationInventoryItem: { findMany: vi.fn(), @@ -11,8 +15,11 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), delete: vi.fn(), }, - }, -})); + $queryRaw: vi.fn(), + $transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(prisma)), + }; + return { prisma }; +}); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); @@ -72,6 +79,12 @@ function jsonReq(url: string, body: unknown, method = "POST"): NextRequest { beforeEach(() => { vi.resetAllMocks(); + // `resetAllMocks` clears the transaction implementation — re-establish it. + ( + prisma as unknown as { $transaction: ReturnType } + ).$transaction.mockImplementation((fn: (tx: unknown) => unknown) => + fn(prisma), + ); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, remaining: 29, diff --git a/src/app/api/mood/tags/__tests__/layout.test.ts b/src/app/api/mood/tags/__tests__/layout.test.ts index e99571dde..03dab52c0 100644 --- a/src/app/api/mood/tags/__tests__/layout.test.ts +++ b/src/app/api/mood/tags/__tests__/layout.test.ts @@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; const db = vi.hoisted(() => ({ - user: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, moodTagCategory: { findMany: vi.fn() }, })); @@ -103,6 +103,8 @@ describe("PUT /api/mood/tags/layout", () => { expect(res.status).toBe(200); expect(db.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, + // v1.32.22 (M2) — tokenless write echoes the fresh `updatedAt` token. + select: { updatedAt: true }, data: { moodTagLayoutJson: { groupOrder: ["custom", "feelings"], diff --git a/src/app/api/mood/tags/layout/__tests__/route.test.ts b/src/app/api/mood/tags/layout/__tests__/route.test.ts new file mode 100644 index 000000000..2417e5b4d --- /dev/null +++ b/src/app/api/mood/tags/layout/__tests__/route.test.ts @@ -0,0 +1,173 @@ +/** + * v1.32.22 (M2) — `PUT /api/mood/tags/layout` optimistic concurrency. + * + * The two manage cards write DIFFERENT fields of the same blob (group order + * vs placements) from the same page; a stale merge would resurrect the other + * card's overwritten field. The base token 409s that instead. A tokenless PUT + * keeps the prior unconditional write. + */ +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(), + updateMany: vi.fn(), + }, + moodTagCategory: { + findMany: vi.fn(), + }, + }, + toJson: (v: T) => v, +})); + +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): NextRequest { + return new NextRequest("http://localhost/api/mood/tags/layout", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never); +}); + +describe("GET /api/mood/tags/layout", () => { + it("returns the layout plus the optimistic-concurrency token", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + moodTagLayoutJson: { groupOrder: ["a", "b"], placements: {} }, + 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: { groupOrder: string[]; updatedAt?: string }; + }; + expect(env.data.updatedAt).toBe("2026-07-24T10:00:00.000Z"); + }); + + it("omits updatedAt for a fresh user (null row)", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + moodTagLayoutJson: null, + } as never); + + const res = await (GET as () => Promise)(); + const env = (await res.json()) as { + data: { updatedAt?: string }; + }; + expect(env.data.updatedAt).toBeUndefined(); + }); +}); + +describe("PUT /api/mood/tags/layout — optimistic concurrency", () => { + it("guards on the base token and echoes the advanced token", async () => { + vi.mocked(prisma.user.findUnique) + // merge read + .mockResolvedValueOnce({ moodTagLayoutJson: null } as never) + // post-write fresh token + .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 (PUT as (r: NextRequest) => Promise)( + mkPut({ + groupOrder: ["a", "b"], + 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.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("interleaved writers: the stale-base second writer gets 409 and clobbers nothing", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + moodTagLayoutJson: { groupOrder: ["a"], placements: {} }, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never); + + const res = await (PUT as (r: NextRequest) => Promise)( + mkPut({ + placements: { a: ["t1"] }, + 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("mood_tag_layout_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(prisma.user.findUnique).mockResolvedValue({ + moodTagLayoutJson: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({ + updatedAt: new Date("2026-07-24T11:00:00.000Z"), + } as never); + + const res = await (PUT as (r: NextRequest) => Promise)( + mkPut({ groupOrder: ["a", "b"] }), + ); + 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 () => { + const res = await (PUT as (r: NextRequest) => Promise)( + mkPut({ groupOrder: ["a"], 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(); + }); +}); diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index f1b7a97ab..88c93e4d1 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -38,7 +38,14 @@ const mutateSpy = vi.fn(); const invalidateSpy = vi.fn(); vi.mock("@tanstack/react-query", () => ({ - useQueryClient: () => ({ invalidateQueries: invalidateSpy }), + useQueryClient: () => ({ + invalidateQueries: invalidateSpy, + // v1.32.22 (M3) — the writer reads/seeds the optimistic-concurrency token + // via the query cache; no token seeded → tokenless (unconditional) write. + getQueryData: () => undefined, + setQueryData: vi.fn(), + removeQueries: vi.fn(), + }), useMutation: (config: MutationConfig) => { lastMutation = config; return { mutate: mutateSpy, isPending: false }; diff --git a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts index 5159ef7e3..1ad03b0e3 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts @@ -29,6 +29,13 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: (...a: unknown[]) => findUnique(...a), update: (...a: unknown[]) => userUpdate(...a), + // v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded + // updateMany; record it on the same spy but return a matched count so the + // guard proceeds (mode unchanged in these tests). + updateMany: (...a: unknown[]) => { + userUpdate(...a); + return Promise.resolve({ count: 1 }); + }, }, auditLog: { deleteMany: vi.fn() }, }, diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index 17fce6442..9b8e680b7 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -29,6 +29,13 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: (...a: unknown[]) => findUnique(...a), update: (...a: unknown[]) => userUpdate(...a), + // v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded + // updateMany; record it on the same spy but return a matched count so the + // guard proceeds (mode unchanged in these tests). + updateMany: (...a: unknown[]) => { + userUpdate(...a); + return Promise.resolve({ count: 1 }); + }, }, auditLog: { deleteMany: vi.fn() }, }, diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts index 205fca7b1..7b439de55 100644 --- a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -46,6 +46,13 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: (...a: unknown[]) => findUnique(...a), update: (...a: unknown[]) => userUpdate(...a), + // v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded + // updateMany; record it on the same spy but return a matched count so the + // guard proceeds (mode unchanged in these tests). + updateMany: (...a: unknown[]) => { + userUpdate(...a); + return Promise.resolve({ count: 1 }); + }, }, auditLog: { deleteMany: vi.fn() }, }, diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index b952be436..049a1863c 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -36,6 +36,13 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: (...a: unknown[]) => findUnique(...a), update: (...a: unknown[]) => userUpdate(...a), + // v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded + // updateMany; record it on the same spy but return a matched count so the + // guard proceeds (mode unchanged in these tests). + updateMany: (...a: unknown[]) => { + userUpdate(...a); + return Promise.resolve({ count: 1 }); + }, }, auditLog: { deleteMany: (...a: unknown[]) => auditDeleteMany(...a), diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index cf1fb723b..7f4a3a8f2 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -27,6 +27,13 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: (...a: unknown[]) => findUnique(...a), update: (...a: unknown[]) => userUpdate(...a), + // v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded + // updateMany; record it on the same spy but return a matched count so the + // guard proceeds (mode unchanged in these tests). + updateMany: (...a: unknown[]) => { + userUpdate(...a); + return Promise.resolve({ count: 1 }); + }, }, auditLog: { deleteMany: vi.fn() }, }, diff --git a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts new file mode 100644 index 000000000..fe7be5219 --- /dev/null +++ b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts @@ -0,0 +1,221 @@ +/** + * v1.32.22 (M6) — a privacy-mode flip that lands WHILE a comprehensive + * generation is in flight must not be overwritten by that generation. + * + * The generation reads `insightsPrivacyMode` at snapshot time and builds its + * payload under that scope. `PUT /api/insights/settings` flips the mode and + * nulls the cache. If the in-flight generation then committed unconditionally + * it would stamp an old-scope briefing over the null the flip wrote. Every + * cache commit is now guarded on the mode still being what the generation + * read: a mismatch (simulated here by the guarded `updateMany` matching zero + * rows) returns `{ status: "skipped", reason: "privacy-mode-changed" }` and + * writes nothing. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findUnique = vi.fn(); +const userUpdate = vi.fn(); +const userUpdateMany = vi.fn(); +const resolveProviderChain = vi.fn(); +const resolveProvider = vi.fn(); +const runRawCompletionWithFallback = vi.fn(); +const extractFeatures = vi.fn(); +const invalidateUserInsights = vi.fn(); +const annotate = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: vi.fn(async () => [{ total_tokens: 0 }]), + $executeRaw: vi.fn(async () => 0), + user: { + findUnique: (...a: unknown[]) => findUnique(...a), + update: (...a: unknown[]) => userUpdate(...a), + // Controllable per test: `{ count: 0 }` emulates a privacy flip having + // advanced the guarded column since the generation read it. + updateMany: (...a: unknown[]) => userUpdateMany(...a), + }, + auditLog: { deleteMany: vi.fn() }, + }, +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), + resolveProvider: (...a: unknown[]) => resolveProvider(...a), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback: (...a: unknown[]) => + runRawCompletionWithFallback(...a), +})); +vi.mock("@/lib/insights/features", () => ({ + FeaturesPayloadTooLargeError: class extends Error { + sizeBytes = 0; + }, + extractFeatures: (...a: unknown[]) => extractFeatures(...a), + BRIEFING_FEATURE_WINDOW_DAYS: 400, +})); +vi.mock("@/lib/insights/illness-cycle-briefing", () => ({ + buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null), + buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""), +})); +vi.mock("@/lib/insights/glp1-plateau", () => ({ + detectGlp1Plateau: vi.fn(async () => null), + buildGlp1PlateauPrompt: vi.fn(() => ""), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(async () => null), + buildAboutMeInsightBlock: vi.fn(() => ""), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserInsights: (...a: unknown[]) => invalidateUserInsights(...a), +})); +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, annotate: (...a: unknown[]) => annotate(...a) }; +}); + +import { generateComprehensiveInsight } from "../comprehensive-generate"; +import { hashInsightSnapshot } from "../snapshot-hash"; +import { compactSections } from "@/lib/ai/prompts/compact-sections"; + +const FEATURES = { weight: { count: 12, latest: 81.4, mean30: 82.1 } }; +const FEATURES_HASH = hashInsightSnapshot({ + features: compactSections(FEATURES as unknown as Record), + aboutMe: null, + comparisonBaseline: "none", + generationLocale: "de", +}); + +const todayKey = new Date().toISOString().slice(0, 10); + +const CACHED_WITH_BRIEFING = JSON.stringify({ + summary: "stable summary", + dailyBriefing: { + paragraph: "Yesterday's phrasing.", + keyFindings: [{ headline: "BP steady", tone: "good" }], + }, +}); + +function annotatedPrivacyChange(): boolean { + return annotate.mock.calls.some( + (c) => + (c[0] as { action?: { name?: string } })?.action?.name === + "insights.generate.privacy_mode_changed", + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: {} }, + ]); + resolveProvider.mockResolvedValue({ type: "none" }); + extractFeatures.mockResolvedValue(FEATURES); + userUpdate.mockResolvedValue({}); +}); + +describe("generateComprehensiveInsight — privacy-mode flip guard (M6)", () => { + it("main commit: refuses to write when the mode flipped, returns privacy-mode-changed", async () => { + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsExcludeMetrics: [], + insightsSnapshotHash: "0".repeat(64), // changed → full generation + }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { + content: JSON.stringify({ dailyBriefing: { p: "new" } }), + tokensUsed: 10, + providerType: "openai", + model: "m", + }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + // The flip landed: the guarded commit matches zero rows. + userUpdateMany.mockResolvedValue({ count: 0 }); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome).toEqual({ + status: "skipped", + reason: "privacy-mode-changed", + }); + // The generation reached the commit (provider was called) … + expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); + // … the commit was the guarded update, keyed on the mode read … + expect(userUpdateMany).toHaveBeenCalledTimes(1); + const where = userUpdateMany.mock.calls[0][0].where as { + insightsPrivacyMode: string; + }; + expect(where.insightsPrivacyMode).toBe("aggregated"); + // … and it did NOT fall back to an unconditional write. + expect(userUpdate).not.toHaveBeenCalled(); + expect(invalidateUserInsights).not.toHaveBeenCalled(); + expect(annotatedPrivacyChange()).toBe(true); + }); + + it("main commit: control — with the mode unchanged the same generation commits", async () => { + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsExcludeMetrics: [], + insightsSnapshotHash: "0".repeat(64), + }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { + content: JSON.stringify({ dailyBriefing: { p: "new" } }), + tokensUsed: 10, + providerType: "openai", + model: "m", + }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + userUpdateMany.mockResolvedValue({ count: 1 }); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome).toEqual({ status: "generated", providerType: "openai" }); + expect(userUpdateMany).toHaveBeenCalledTimes(1); + expect(invalidateUserInsights).toHaveBeenCalledWith("u1"); + expect(annotatedPrivacyChange()).toBe(false); + }); + + it("reroll arm: a flip refuses the paragraph re-roll write too", async () => { + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), + insightsCachedText: CACHED_WITH_BRIEFING, + insightsExcludeMetrics: [], + insightsSnapshotHash: FEATURES_HASH, // unchanged → reroll path + insightsBriefingRerollDate: null, + }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { + content: JSON.stringify({ + dailyBriefing: { paragraph: "Today's fresh phrasing." }, + }), + tokensUsed: 50, + providerType: "openai", + model: "m", + }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + userUpdateMany.mockResolvedValue({ count: 0 }); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome).toEqual({ + status: "skipped", + reason: "privacy-mode-changed", + }); + expect(userUpdateMany).toHaveBeenCalledTimes(1); + expect(invalidateUserInsights).not.toHaveBeenCalled(); + expect(annotatedPrivacyChange()).toBe(true); + // Not marked as a used-today reroll, since nothing committed. + expect(todayKey).toBe(new Date().toISOString().slice(0, 10)); + }); +}); diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts index e9c65af4a..2c8692e50 100644 --- a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -31,6 +31,13 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: (...a: unknown[]) => findUnique(...a), update: (...a: unknown[]) => userUpdate(...a), + // v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded + // updateMany; record it on the same spy but return a matched count so the + // guard proceeds (mode unchanged in these tests). + updateMany: (...a: unknown[]) => { + userUpdate(...a); + return Promise.resolve({ count: 1 }); + }, }, auditLog: { deleteMany: vi.fn(), create: vi.fn() }, }, diff --git a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts index c7b385097..138aff034 100644 --- a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -33,6 +33,13 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: (...a: unknown[]) => findUnique(...a), update: (...a: unknown[]) => userUpdate(...a), + // v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded + // updateMany; record it on the same spy but return a matched count so the + // guard proceeds (mode unchanged in these tests). + updateMany: (...a: unknown[]) => { + userUpdate(...a); + return Promise.resolve({ count: 1 }); + }, }, auditLog: { deleteMany: vi.fn() }, },