test(concurrency): cover the prefs guards, the inventory lock, and the privacy re-check

Add interleaved-writer, tokenless-compat, and malformed-token coverage for the
notification-prefs, modules, coach-prefs, and mood-tag-layout write guards,
including the notification-prefs case where a stale web write must not revert
the app-managed medication flag. Pin the inventory PATCH and DELETE routing
through the advisory lock, and cover the insight-generation privacy re-check on
both the main commit and the reroll arm.
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 18:08:01 +02:00
1 parent 3f33a30415
commit f9afa794e9
16 files changed
+959 -11

No files matched your search

@@ -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<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/coach-prefs", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("GET /api/auth/me/coach-prefs", () => {
it("returns 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<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { tone: string; updatedAt?: string };
};
expect(env.data.tone).toBe("warm");
expect(env.data.updatedAt).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<Response>)(
mkPut({
excludeMetrics: ["weight"],
baseUpdatedAt: "2026-07-24T10:00:00.000Z",
}),
);
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0] as {
where: { id: string; updatedAt: Date };
data: { coachPrefsJson: unknown; insightsExcludeMetrics: string[] };
};
expect((call.where.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
// Both mirror columns ride the SAME guarded update.
expect(call.data.coachPrefsJson).toBeTruthy();
expect(call.data.insightsExcludeMetrics).toEqual(["weight"]);
expect(prisma.user.update).not.toHaveBeenCalled();
const env = (await res.json()) as { data: { updatedAt: string } };
expect(env.data.updatedAt).toBe("2026-07-24T10:05:00.000Z");
});
it("interleaved writers: a stale token 409s and leaves BOTH columns untouched", async () => {
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({
excludeMetrics: ["weight"],
baseUpdatedAt: "2026-07-24T08:00:00.000Z",
}),
);
expect(res.status).toBe(409);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("coach_prefs_conflict");
// Nothing written — the single guarded update matched no row.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the token is omitted (compat)", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ excludeMetrics: ["weight"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
data: { coachPrefsJson: unknown; insightsExcludeMetrics: string[] };
};
expect(call.data.insightsExcludeMetrics).toEqual(["weight"]);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ excludeMetrics: ["weight"], baseUpdatedAt: "not-a-date" }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
@@ -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<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("interleaved writers: the stale-base second writer gets 409 and clobbers nothing", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T08:00:00.000Z" }),
);
expect(res.status).toBe(409);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("modules_conflict");
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the token is omitted (compat)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("strips the token pre-parse: a token ALONE does not trip the strict schema", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
// 200, not 422 — `baseUpdatedAt` was removed before the strict parse.
expect(res.status).toBe(200);
});
it("still 422s an unknown key even alongside a valid token (strict stays strict)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ notAModule: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "not-a-date" }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
@@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
toJson: <T>(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<Response>)(
mkTokenedPatch(
{ medication: { clientManaged: true } },
"2026-07-24T10:00:00.000Z",
),
);
expect(res.status).toBe(200);
// Conditional write, no unconditional fallback.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
expect(prisma.user.update).not.toHaveBeenCalled();
const env = (await res.json()) as { data: { updatedAt: string } };
expect(env.data.updatedAt).toBe("2026-07-24T10:05:00.000Z");
});
it("regression: a stale-token web write does NOT revert the iOS clientManaged flag", async () => {
// iOS set medication.clientManaged = true (row now carries it). A web card
// PATCH based on a pre-iOS read of a DIFFERENT category arrives with a stale
// token: it must 409 and write NOTHING, so clientManaged survives.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch({ mood: { reminderHour: 9 } }, "2026-07-24T08:00:00.000Z"),
);
expect(res.status).toBe(409);
const env = (await res.json()) as {
data: null;
meta?: { errorCode?: string };
};
expect(env.data).toBeNull();
expect(env.meta?.errorCode).toBe("notification_prefs_conflict");
// The guarded write matched no row; nothing was written, so the persisted
// clientManaged flag is untouched.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("compat: the SAME second write TOKENLESS still lands (old clients keep last-write-wins)", async () => {
// Deliberate: a tokenless client keeps the pre-guard race. This pins that
// the clobber for old clients is a documented choice, not an accident.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: { reminderHour: 9 } }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch({ medication: { clientManaged: true } }, "not-a-date"),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -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<string, unknown> = {
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<typeof vi.fn>;
$transaction: ReturnType<typeof vi.fn>;
};
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<string, unknown> }) => ({
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();
});
});
@@ -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<string, unknown> = {
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<typeof vi.fn> }
).$transaction.mockImplementation((fn: (tx: unknown) => unknown) =>
fn(prisma),
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
@@ -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"],
@@ -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: <T>(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<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPut(body: unknown): 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<Response>)();
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<Response>)();
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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();
});
});
@@ -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 };
@@ -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() },
},
@@ -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() },
},
@@ -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() },
},
@@ -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),
@@ -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() },
},
@@ -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<typeof import("@/lib/logging/context")>();
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<string, unknown>),
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));
});
});
@@ -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() },
},
@@ -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() },
},