mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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() },
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user