mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
test(integration): move the account preference route tests onto Postgres
Fourteen route tests for the /api/auth/me preference family replaced Prisma with a mock and asserted the calls it received. Their claims are now shown through the real handler on the stored row and the response: validation refusals with their codes, normalisation and defaults on read, optimistic lock conflicts against a real second writer, rate limits driven to their first refusal in the Postgres limiter, audit rows read back, cache entries evicted, and the jobs a change queues observed on a real pg-boss queue. Assertions that were only about the shape of a Prisma call are replaced by the behaviour they stood for, and two that described a state only a mock can reach (a missing column) by the stored value that state resolves to.
This commit is contained in:
1 parent
09f088e8a7
commit
c0c0fdb421
27 files changed
+3525
-3893
No files matched your search
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* `PUT /api/auth/me/ai-usage` (#1217): the account's own level, within the
|
||||
* operator's ceiling. A level above it is refused with 422
|
||||
* `ai.usage.aboveCeiling` and `meta.maxLevel`, never capped silently; `null`
|
||||
* returns to the instance default; the answer is the resolved `ai.usage`.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
stored: null as string | null,
|
||||
settings: {
|
||||
aiUsageMaxLevel: "STANDARD",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiSyncRegenGapHours: null,
|
||||
} as Record<string, unknown>,
|
||||
usage: [] as Array<{ totalTokens: number }>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
aiUsageLevel: db.stored,
|
||||
timezone: "UTC",
|
||||
})),
|
||||
update: vi.fn(async (args: { data: { aiUsageLevel: string | null } }) => {
|
||||
db.stored = args.data.aiUsageLevel;
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
appSettings: { findUnique: vi.fn(async () => db.settings) },
|
||||
coachUsage: { findMany: vi.fn(async () => db.usage) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "UTC"),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const put = PUT as unknown as (req: Request) => Promise<Response>;
|
||||
|
||||
function req(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/ai-usage", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.stored = null;
|
||||
db.settings = {
|
||||
aiUsageMaxLevel: "STANDARD",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiSyncRegenGapHours: null,
|
||||
};
|
||||
db.usage = [];
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/ai-usage", () => {
|
||||
it("stores a level within the ceiling and answers the resolved state", async () => {
|
||||
db.usage = [
|
||||
{ totalTokens: 90_000 },
|
||||
{ totalTokens: 110_000 },
|
||||
{ totalTokens: 100_000 },
|
||||
];
|
||||
const res = await put(req({ level: "frugal" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(prisma.user.update).mock.calls[0]![0]).toEqual({
|
||||
where: { id: "user-1" },
|
||||
data: { aiUsageLevel: "FRUGAL" },
|
||||
});
|
||||
const { data } = await res.json();
|
||||
expect(data).toMatchObject({
|
||||
level: "frugal",
|
||||
preference: "frugal",
|
||||
defaultLevel: "standard",
|
||||
maxLevel: "standard",
|
||||
source: "user",
|
||||
measuredTokensPerDay: 100_000,
|
||||
measuredDays: 3,
|
||||
});
|
||||
expect(data.options).toEqual([
|
||||
{ level: "frugal", allowed: true, estimatedTokensPerDay: 100_000 },
|
||||
{ level: "standard", allowed: true, estimatedTokensPerDay: 200_000 },
|
||||
{ level: "full", allowed: false, estimatedTokensPerDay: 666_667 },
|
||||
]);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.aiUsage.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: { previous: null, next: "frugal" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a level above the ceiling with the ceiling named", async () => {
|
||||
const res = await put(req({ level: "full" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toMatchObject({
|
||||
errorCode: "ai.usage.aboveCeiling",
|
||||
maxLevel: "standard",
|
||||
});
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts full once the operator allows it", async () => {
|
||||
db.settings = { ...db.settings, aiUsageMaxLevel: "FULL" };
|
||||
const res = await put(req({ level: "full" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.level).toBe("full");
|
||||
});
|
||||
|
||||
it("returns to the instance default on null", async () => {
|
||||
db.stored = "FRUGAL";
|
||||
const res = await put(req({ level: null }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(prisma.user.update).mock.calls[0]![0].data).toEqual({
|
||||
aiUsageLevel: null,
|
||||
});
|
||||
const { data } = await res.json();
|
||||
expect(data).toMatchObject({
|
||||
level: "standard",
|
||||
preference: null,
|
||||
source: "default",
|
||||
});
|
||||
// Fewer than three days of history: no estimate.
|
||||
expect(
|
||||
data.options.every(
|
||||
(o: { estimatedTokensPerDay: number | null }) =>
|
||||
o.estimatedTokensPerDay === null,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ level: "lavish" }],
|
||||
[{}],
|
||||
[{ level: "frugal", extra: true }],
|
||||
[{ level: 1 }],
|
||||
])("refuses %j as invalid", async (body) => {
|
||||
const res = await put(req(body));
|
||||
expect(res.status).toBe(422);
|
||||
expect((await res.json()).meta.errorCode).toBe("ai.usage.invalid");
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses the 31st change in an hour", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await put(req({ level: "frugal" }));
|
||||
expect(res.status).toBe(429);
|
||||
expect((await res.json()).meta.errorCode).toBe("ai.usage.rateLimited");
|
||||
expect(vi.mocked(checkRateLimit).mock.calls[0]!.slice(1)).toEqual([
|
||||
30,
|
||||
60 * 60 * 1000,
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a caller without a session", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await put(req({ level: "frugal" }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* v1.32.22 — `PUT /api/auth/me/coach-prefs` optimistic concurrency.
|
||||
*
|
||||
* A full-replace endpoint hydrated from a stale GET (two tabs / two devices)
|
||||
* clobbers silently; the base token 409s that. The guarded write covers BOTH
|
||||
* `coachPrefsJson` AND the mirrored `insightsExcludeMetrics` column in one
|
||||
* conditional update, so a 409 leaves both untouched together. A tokenless PUT
|
||||
* keeps the prior unconditional write.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<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 a SAVED user's prefs plus the optimistic-concurrency token", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
coachPrefsJson: { tone: "concise" },
|
||||
updatedAt: new Date("2026-07-24T10:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { tone: string; updatedAt?: string };
|
||||
};
|
||||
expect(env.data.tone).toBe("concise");
|
||||
expect(env.data.updatedAt).toBe("2026-07-24T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns the PURE default with NO token for a never-saved user", async () => {
|
||||
// Mirrors the insights-layout never-saved GET: nothing to guard yet, and
|
||||
// the first save is the tokenless unconditional write (backward-compat).
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
coachPrefsJson: null,
|
||||
updatedAt: new Date("2026-07-24T10:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
const res = await (GET as () => Promise<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).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => {
|
||||
it("guards both columns in one conditional write and echoes the token", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
|
||||
} as never);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/coach-prefs — thinking depth (v1.41)", () => {
|
||||
it("stores the chosen level and names it on the wide event", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
updatedAt: new Date("2026-10-06T11:00:00.000Z"),
|
||||
} as never);
|
||||
const { annotate } = await import("@/lib/logging/context");
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ reasoning: "high" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
|
||||
data: { coachPrefsJson: { reasoning?: string } };
|
||||
};
|
||||
expect(call.data.coachPrefsJson.reasoning).toBe("high");
|
||||
const env = (await res.json()) as { data: { reasoning?: string } };
|
||||
expect(env.data.reasoning).toBe("high");
|
||||
const meta = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta;
|
||||
expect(meta).toMatchObject({ reasoning: "high" });
|
||||
});
|
||||
|
||||
it("reads an absent level as the default on the wide event", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
updatedAt: new Date("2026-10-06T11:00:00.000Z"),
|
||||
} as never);
|
||||
const { annotate } = await import("@/lib/logging/context");
|
||||
|
||||
await (PUT as (r: Request) => Promise<Response>)(mkPut({ tone: "warm" }));
|
||||
const meta = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta;
|
||||
expect(meta).toMatchObject({ reasoning: "medium" });
|
||||
});
|
||||
|
||||
it.each(["xhigh", "none", 2, null])("422s %s as a level", async (value) => {
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ reasoning: value }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* The declared diabetes flag is the one preference that switches every
|
||||
* glucose surface from the general normal bands to the ADA glycemic goal
|
||||
* bands and reaches the Coach snapshot. The web toggle in the thresholds
|
||||
* section rides this route, so the round trip has to hold: a PATCH writes
|
||||
* the column and echoes the resolved state, and a GET reads it back.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkGet(): Request {
|
||||
return new Request("http://localhost/api/auth/me/diabetes");
|
||||
}
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/diabetes", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/diabetes", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false for a user who never declared the flag", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
hasDiabetes: false,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { hasDiabetes: boolean } };
|
||||
expect(env.data.hasDiabetes).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after the flag was declared", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
hasDiabetes: true,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { hasDiabetes: boolean } };
|
||||
expect(env.data.hasDiabetes).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/diabetes", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ hasDiabetes: true }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes the flag on, echoes it back, and audits the transition", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
hasDiabetes: false,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ hasDiabetes: true }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { hasDiabetes: boolean } };
|
||||
expect(env.data.hasDiabetes).toBe(true);
|
||||
|
||||
// The write must actually land, scoped to the caller, field-by-field.
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { hasDiabetes: true },
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.diabetes.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({ previous: false, next: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes the flag off and echoes the resolved state", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
hasDiabetes: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ hasDiabetes: false }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { hasDiabetes: boolean } };
|
||||
expect(env.data.hasDiabetes).toBe(false);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { hasDiabetes: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a missing hasDiabetes field with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ otherField: true }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-boolean hasDiabetes field with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ hasDiabetes: "true" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate-limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ hasDiabetes: true }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/disable-coach", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/disable-coach", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/disable-coach"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false for a fresh user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: false,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/disable-coach"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for an opted-out user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: true,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/disable-coach"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the column is missing (partial-deploy rollback)", async () => {
|
||||
// Defensive default — the API surface tolerates a `findUnique`
|
||||
// result that doesn't contain the column. Belt-and-braces with the
|
||||
// schema-level NOT NULL default; both layers keep the Coach
|
||||
// visible by default for users who never opted out.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/disable-coach"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/disable-coach", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: true }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flips the column on and writes the enable audit row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: false,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: true }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(true);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { disableCoach: true },
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.disable-coach.enable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: false,
|
||||
next: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("flips the column off and writes the disable audit row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: false }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(false);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { disableCoach: false },
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.disable-coach.disable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: true,
|
||||
next: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a missing disableCoach field with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ otherField: "ignored" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-boolean disableCoach field with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: "true" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 400, not the 422 this used to pin. A body that will not parse is a
|
||||
// client-side serialisation fault, and `safeJson` plus roughly two hundred
|
||||
// and twenty other routes have always answered 400 for it; the dotted token
|
||||
// moved to `meta.errorCode`, which is where a machine code belongs.
|
||||
it("rejects malformed JSON with 400", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const req = new Request("http://localhost/api/auth/me/disable-coach", {
|
||||
method: "PATCH",
|
||||
body: "{ not valid json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: "Invalid JSON body",
|
||||
meta: { errorCode: "disable-coach.body.invalid_json" },
|
||||
});
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent when the column already holds the requested value", async () => {
|
||||
// Toggling a column from true→true (or false→false) still writes
|
||||
// through so the audit row exists. Matches the doctor-report-prefs
|
||||
// route's "always write" posture.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: true }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate-limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: true }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* The strict half, end to end on a real route.
|
||||
*
|
||||
* `patchBodySchema` is `.strict()`, so a PATCH carrying a key the route
|
||||
* does not declare is refused rather than trimmed down to the keys it
|
||||
* does. Before that, `{ disableCoachh: true }` parsed clean as `{}` —
|
||||
* except `disableCoach` is required, so this particular body already
|
||||
* 422'd on the missing field. The one worth pinning is the body that
|
||||
* used to succeed while doing something other than what it said:
|
||||
* `{ disableCoach: true, disableCoachh: false }`.
|
||||
*
|
||||
* The second assertion is the user-visible half. A 422 that says only
|
||||
* "Validation failed" leaves the caller staring at a body it believes is
|
||||
* correct, so the envelope has to name the key it refused — and name it
|
||||
* without reflecting a hostile one back verbatim.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/disable-coach", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const patch = PATCH as (r: Request) => Promise<Response>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
disableCoach: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/disable-coach — unknown keys", () => {
|
||||
it("accepts the declared body", async () => {
|
||||
const res = await patch(mkPatch({ disableCoach: true }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refuses a body that carries an undeclared key alongside a valid one", async () => {
|
||||
const res = await patch(
|
||||
mkPatch({ disableCoach: true, disableCoachh: false }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
// Nothing was written. Before `.strict()` this wrote `disableCoach:
|
||||
// true` and answered 200, so a caller that meant the second field
|
||||
// was told its request had been honoured.
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the offending key in the 422", async () => {
|
||||
const res = await patch(
|
||||
mkPatch({ disableCoach: true, disableCoachh: false }),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
error: string;
|
||||
details?: {
|
||||
issues: Array<{ code: string; message: string; keys?: string[] }>;
|
||||
};
|
||||
};
|
||||
const issue = body.details?.issues.find(
|
||||
(i) => i.code === "unrecognized_keys",
|
||||
);
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue?.keys).toEqual(["disableCoachh"]);
|
||||
expect(issue?.message).toContain("disableCoachh");
|
||||
});
|
||||
|
||||
it("does not reflect a hostile key name back verbatim", async () => {
|
||||
const res = await patch(
|
||||
mkPatch({ disableCoach: true, "<script>alert(1)</script>": 1 }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const raw = await res.text();
|
||||
expect(raw).not.toContain("<script>");
|
||||
expect(raw).toContain("script_alert_1");
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* The auto-read opt-in endpoint, and specifically the catch-up it schedules.
|
||||
*
|
||||
* The summary job is enqueued at UPLOAD time and no-ops while the flag is OFF,
|
||||
* so without a catch-up on the flip the toggle only ever applied to future
|
||||
* uploads — a user who filled the vault first saw the switch do nothing. These
|
||||
* tests pin that a genuine OFF→ON transition schedules the pass, and that a
|
||||
* no-op re-save or a flip to OFF does not.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
consentReceipt: { findFirst: vi.fn(), create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
getAiCapability: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/document-summary-catchup", () => ({
|
||||
enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPatch(documentsAutoAiRead: boolean): Request {
|
||||
return new Request("http://localhost/api/auth/me/documents-auto-ai-read", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ documentsAutoAiRead }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Seed the flag's value BEFORE the PATCH under test. */
|
||||
function withPrevious(documentsAutoAiRead: boolean) {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
documentsAutoAiRead,
|
||||
} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValue({} as never);
|
||||
vi.mocked(getAiCapability).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () => {
|
||||
it("schedules a catch-up when the opt-in flips OFF→ON", async () => {
|
||||
withPrevious(false);
|
||||
|
||||
const res = await PATCH(mkPatch(true));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(enqueueSummaryCatchUp).toHaveBeenCalledWith("user-1");
|
||||
expect(enqueueSummaryCatchUp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not re-schedule when the opt-in was already ON", async () => {
|
||||
// Idempotency at the trigger: re-saving an already-ON setting must not
|
||||
// queue a second pass over the same documents.
|
||||
withPrevious(true);
|
||||
|
||||
await PATCH(mkPatch(true));
|
||||
|
||||
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not schedule anything when the opt-in is turned OFF", async () => {
|
||||
withPrevious(true);
|
||||
|
||||
await PATCH(mkPatch(false));
|
||||
|
||||
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mints the extraction receipt on the flip that schedules the pass", async () => {
|
||||
// The catch-up rides the same act of consent, never around it. The toggle
|
||||
// grants document reading only: `ai_extraction`, not the master `ai_full`
|
||||
// that would also open the Coach and the AI analysis.
|
||||
withPrevious(false);
|
||||
|
||||
await PATCH(mkPatch(true));
|
||||
|
||||
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
|
||||
const data = vi.mocked(prisma.consentReceipt.create).mock.calls[0]![0]
|
||||
.data as { userId: string; kind: string };
|
||||
expect(data.userId).toBe("user-1");
|
||||
expect(data.kind).toBe("ai_extraction");
|
||||
// The capability is read after the mint, so the fresh receipt counts.
|
||||
const mintOrder = vi.mocked(prisma.consentReceipt.create).mock
|
||||
.invocationCallOrder[0]!;
|
||||
const readOrder = vi.mocked(getAiCapability).mock.invocationCallOrder[0]!;
|
||||
expect(mintOrder).toBeLessThan(readOrder);
|
||||
});
|
||||
|
||||
it("does not mint a second receipt when one that covers document reads is active", async () => {
|
||||
withPrevious(false);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
|
||||
id: "r1",
|
||||
} as never);
|
||||
|
||||
await PATCH(mkPatch(true));
|
||||
|
||||
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
revokedAt: null,
|
||||
kind: { in: ["ai_extraction", "ai_full"] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a concurrent mint as success", async () => {
|
||||
withPrevious(false);
|
||||
vi.mocked(prisma.consentReceipt.create).mockRejectedValue({
|
||||
code: "P2002",
|
||||
});
|
||||
|
||||
const res = await PATCH(mkPatch(true));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("saves the preference but schedules no catch-up while document reading is closed", async () => {
|
||||
// The operator turned document reading off: every summary the pass would
|
||||
// enqueue could only be refused, so it is not enqueued at all.
|
||||
withPrevious(false);
|
||||
vi.mocked(getAiCapability).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
|
||||
const res = await PATCH(mkPatch(true));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalled();
|
||||
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists the flag field-by-field", async () => {
|
||||
withPrevious(false);
|
||||
|
||||
await PATCH(mkPatch(true));
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { documentsAutoAiRead: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,247 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* The blood-glucose display unit — the writer the column never had.
|
||||
*
|
||||
* `User.glucoseUnit` shipped in v1.2 and about thirty surfaces read it, but
|
||||
* no route, form or schema could ever set it, so every account stayed on the
|
||||
* mg/dL default and the mmol/L half of the app was dead code. This pins the
|
||||
* missing end: a PATCH writes the column and echoes the resolved state, a GET
|
||||
* reads it back, and an unrecognised unit is refused rather than stored —
|
||||
* a stray string in that column would resolve to mg/dL and quietly put every
|
||||
* mmol/L reader back on the wrong number.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/insights/unit-change-refresh", () => ({
|
||||
refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkGet(): Request {
|
||||
return new Request("http://localhost/api/auth/me/glucose-unit");
|
||||
}
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/glucose-unit", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/glucose-unit", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves an account that never chose to mg/dL", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { glucoseUnit: string } };
|
||||
expect(env.data.glucoseUnit).toBe("mg/dL");
|
||||
});
|
||||
|
||||
it("reads back the unit the account chose", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mmol/L",
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
|
||||
const env = (await res.json()) as { data: { glucoseUnit: string } };
|
||||
expect(env.data.glucoseUnit).toBe("mmol/L");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/glucose-unit", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ glucoseUnit: "mmol/L" }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves an account to mmol/L, echoes it, and audits the transition", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ glucoseUnit: "mmol/L" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { glucoseUnit: string } };
|
||||
expect(env.data.glucoseUnit).toBe("mmol/L");
|
||||
|
||||
// The write must actually land, scoped to the caller, field-by-field.
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { glucoseUnit: "mmol/L" },
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.glucose-unit.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: "mg/dL",
|
||||
next: "mmol/L",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("moves an account back to mg/dL", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mmol/L",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ glucoseUnit: "mg/dL" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { glucoseUnit: "mg/dL" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a unit outside the two the app renders", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
for (const bad of ["mmol/l", "mgdl", "imperial", "", 1]) {
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ glucoseUnit: bad }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
}
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a missing glucoseUnit field with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ otherField: "mmol/L" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate-limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ glucoseUnit: "mmol/L" }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH re-warms the texts that quote the unit", () => {
|
||||
it("re-warms the stored notes when the unit actually changes", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ glucoseUnit: "mmol/L" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("leaves them alone when the same unit is saved again", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mmol/L",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ glucoseUnit: "mmol/L" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,572 +0,0 @@
|
||||
/**
|
||||
* v1.35.0 — GET/PATCH /api/auth/me/health-score-config.
|
||||
*
|
||||
* Covers the read projection, the positive-selection-in /
|
||||
* deselection-out inversion, the write-time breadth refusal, the
|
||||
* monotonic recipe version, and the score-cache eviction. The
|
||||
* invalidation assertion is here because a missing one is invisible:
|
||||
* the write succeeds, the response is right, and the person keeps
|
||||
* seeing the old number for an hour.
|
||||
*/
|
||||
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/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/cache/invalidate")>();
|
||||
return { ...actual, invalidateUserHealthScore: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import type { ScorePillarId } from "@/lib/analytics/score/types";
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const ALL_PILLARS: ScorePillarId[] = [
|
||||
"BLOOD_PRESSURE",
|
||||
"GLYCAEMIA",
|
||||
"ACTIVITY",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"LIPIDS",
|
||||
];
|
||||
|
||||
interface ResolvedBody {
|
||||
pillars: ScorePillarId[];
|
||||
excludedPillars: ScorePillarId[];
|
||||
hasSelection: boolean;
|
||||
version: number;
|
||||
changedAt: string | null;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/health-score-config", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Mutable row, so a write is visible to the read that follows it. */
|
||||
function primeUser(healthScoreConfigJson: unknown = null) {
|
||||
const row: { healthScoreConfigJson: unknown; updatedAt: Date } = {
|
||||
healthScoreConfigJson,
|
||||
updatedAt: new Date("2026-07-31T09:00:00.000Z"),
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation((() =>
|
||||
Promise.resolve({ ...row })) as never);
|
||||
vi.mocked(prisma.user.update).mockImplementation(((args: {
|
||||
data?: { healthScoreConfigJson?: unknown };
|
||||
}) => {
|
||||
if (args.data && "healthScoreConfigJson" in args.data) {
|
||||
row.healthScoreConfigJson = args.data.healthScoreConfigJson;
|
||||
}
|
||||
return Promise.resolve({ updatedAt: row.updatedAt });
|
||||
}) as never);
|
||||
return row;
|
||||
}
|
||||
|
||||
function writtenConfig(): {
|
||||
excludedPillars: ScorePillarId[];
|
||||
version: number;
|
||||
changedAt: string;
|
||||
} {
|
||||
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
|
||||
data: {
|
||||
healthScoreConfigJson: {
|
||||
excludedPillars: ScorePillarId[];
|
||||
version: number;
|
||||
changedAt: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
return call.data.healthScoreConfigJson;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/health-score-config", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/health-score-config"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("reports every pillar counting, and no selection, for a fresh account", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/health-score-config"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
expect(env.data.pillars).toEqual(ALL_PILLARS);
|
||||
expect(env.data.hasSelection).toBe(false);
|
||||
expect(env.data.version).toBe(0);
|
||||
expect(env.data.updatedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports a stored selection as the pillars it leaves counting", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({
|
||||
excludedPillars: ["SLEEP", "LIPIDS"],
|
||||
version: 2,
|
||||
changedAt: "2026-07-30T08:00:00.000Z",
|
||||
});
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/health-score-config"),
|
||||
);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
expect(env.data.pillars).toEqual(
|
||||
ALL_PILLARS.filter((id) => id !== "SLEEP" && id !== "LIPIDS"),
|
||||
);
|
||||
expect(env.data.excludedPillars).toEqual(["SLEEP", "LIPIDS"]);
|
||||
expect(env.data.hasSelection).toBe(true);
|
||||
expect(env.data.version).toBe(2);
|
||||
expect(env.data.updatedAt).toBe("2026-07-31T09:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/health-score-config", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the complement of the selection and returns the selection", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
const selection: ScorePillarId[] = [
|
||||
"BLOOD_PRESSURE",
|
||||
"ACTIVITY",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
];
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: selection }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
expect(env.data.pillars).toEqual(selection);
|
||||
expect(env.data.hasSelection).toBe(true);
|
||||
|
||||
const stored = writtenConfig();
|
||||
expect(stored.excludedPillars).toEqual(
|
||||
ALL_PILLARS.filter((id) => !selection.includes(id)),
|
||||
);
|
||||
expect(stored.version).toBe(1);
|
||||
expect(stored.changedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.health_score_config.update",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("increments the recipe version on every accepted write", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({
|
||||
excludedPillars: ["LIPIDS"],
|
||||
version: 7,
|
||||
changedAt: "2026-07-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
expect(writtenConfig().version).toBe(8);
|
||||
});
|
||||
|
||||
it("keeps a kept-everything selection as an authored choice", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
expect(writtenConfig().excludedPillars).toEqual([]);
|
||||
expect(env.data.hasSelection).toBe(true);
|
||||
});
|
||||
|
||||
it("evicts the score caches so the old number is not served on", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38 — what the write still refuses, after the breadth floor became a
|
||||
* recommendation.
|
||||
*
|
||||
* Two of the three refusals this block used to pin are gone. The route
|
||||
* turned away `["ACTIVITY", "WELLBEING"]` with
|
||||
* `measured_physiological_domain_required` and the cardiometabolic
|
||||
* triple with `three_domains_required`, both on the ground that the
|
||||
* selection could not produce a score. It can now — the scorer computes
|
||||
* from whatever is readable and labels how broad the set was — and a
|
||||
* write that kept refusing what the read is willing to serve would be
|
||||
* the two layers disagreeing, which is the drift the shared rule exists
|
||||
* to prevent. So those two assertions are inverted to acceptances.
|
||||
*
|
||||
* The empty selection stays refused, and that case is why this is a
|
||||
* change of rule rather than a removal of one.
|
||||
*/
|
||||
describe("PATCH — the breadth rule", () => {
|
||||
it("accepts activity and wellbeing alone, and stores them", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ["ACTIVITY", "WELLBEING"] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("accepts the cardiometabolic triple, which is three pillars in one area", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("accepts a single pillar", async () => {
|
||||
// The narrowest selection that can still be scored, and the boundary
|
||||
// the one surviving refusal sits against.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ["ACTIVITY"] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refuses an empty selection rather than storing a score that cannot exist", async () => {
|
||||
// The counter-case. A write that accepted everything would have
|
||||
// stopped enforcing anything, and this is the one selection the
|
||||
// scorer genuinely cannot answer.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: [] }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const env = (await res.json()) as {
|
||||
error: string;
|
||||
meta?: { errorCode?: string; reason?: string };
|
||||
};
|
||||
expect(env.meta?.errorCode).toBe("health_score_config.too_narrow");
|
||||
expect(env.meta?.reason).toBe("no_pillars_selected");
|
||||
expect(env.error).toContain("at least one area of health");
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a three-area selection", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH — envelope and transport", () => {
|
||||
it("rejects an unknown pillar id with 422 and writes nothing", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ["BLOOD_PRESSURE", "MOON_PHASE", "SLEEP"] }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unknown body key with 422 (strict stays strict)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS, weights: { SLEEP: 2 } }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a userId in the body", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS, userId: "user-2" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const req = new Request(
|
||||
"http://localhost/api/auth/me/health-score-config",
|
||||
{
|
||||
method: "PATCH",
|
||||
body: "{ not valid json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("guards the write on the base token and 409s a stale one", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({
|
||||
pillars: ALL_PILLARS,
|
||||
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("health_score_config_conflict");
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
// A write that never landed must not evict the caches either.
|
||||
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.35.1 — the route shipped in v1.35.0 with a settings surface in front
|
||||
* of it and no published contract at all: `grep -c health-score-config
|
||||
* docs/api/openapi.yaml` returned 0. This block is the second end.
|
||||
*
|
||||
* It does not compare the spec against a restatement of the spec. It drives
|
||||
* the REAL route to produce each response, reads the codes and keys off the
|
||||
* bytes it actually returns, and asserts the published contract names them.
|
||||
* A response the route can emit but the contract does not describe fails
|
||||
* here, which is the failure the release itself did not catch.
|
||||
*/
|
||||
describe("the published contract matches what the route serves", () => {
|
||||
const PATH = "/api/auth/me/health-score-config";
|
||||
|
||||
function doc() {
|
||||
return buildOpenApiDocument() as {
|
||||
paths?: Record<
|
||||
string,
|
||||
Record<string, { responses?: Record<string, { description?: string }> }>
|
||||
>;
|
||||
components?: {
|
||||
schemas?: Record<
|
||||
string,
|
||||
{ properties?: Record<string, unknown>; required?: string[] }
|
||||
>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Every `meta.errorCode` the PATCH route can actually produce. */
|
||||
async function emittedErrorCodes(): Promise<Map<number, Set<string>>> {
|
||||
const byStatus = new Map<number, Set<string>>();
|
||||
const record = async (body: unknown, conflict = false) => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({
|
||||
count: conflict ? 0 : 1,
|
||||
} as never);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch(body),
|
||||
);
|
||||
const env = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
if (env.meta?.errorCode) {
|
||||
const set = byStatus.get(res.status) ?? new Set<string>();
|
||||
set.add(env.meta.errorCode);
|
||||
byStatus.set(res.status, set);
|
||||
}
|
||||
};
|
||||
|
||||
// Too narrow. One reason since v1.38, because one selection is: the
|
||||
// empty one.
|
||||
await record({ pillars: [] });
|
||||
// Body that fails validation.
|
||||
await record({ pillars: ["NOT_A_PILLAR"] });
|
||||
// Present-but-unparseable base token.
|
||||
await record({ pillars: ALL_PILLARS, baseUpdatedAt: null });
|
||||
// Stale base token.
|
||||
await record(
|
||||
{ pillars: ALL_PILLARS, baseUpdatedAt: "2026-07-24T08:00:00.000Z" },
|
||||
true,
|
||||
);
|
||||
return byStatus;
|
||||
}
|
||||
|
||||
it("registers both verbs", () => {
|
||||
const path = doc().paths?.[PATH];
|
||||
expect(
|
||||
path,
|
||||
`${PATH} is not registered in the OpenAPI document`,
|
||||
).toBeDefined();
|
||||
expect(Object.keys(path ?? {}).sort()).toEqual(["get", "patch"]);
|
||||
});
|
||||
|
||||
it("names every errorCode the route can emit, on the right status", async () => {
|
||||
const emitted = await emittedErrorCodes();
|
||||
const responses = doc().paths?.[PATH]?.patch?.responses ?? {};
|
||||
|
||||
// Sanity: the driver really did produce the codes, so a contract that
|
||||
// named none of them could not pass by the loop never running.
|
||||
expect([...(emitted.get(422) ?? [])].sort()).toEqual([
|
||||
"health_score_config.invalid",
|
||||
"health_score_config.too_narrow",
|
||||
"invalid_base_updated_at",
|
||||
]);
|
||||
expect([...(emitted.get(409) ?? [])]).toEqual([
|
||||
"health_score_config_conflict",
|
||||
]);
|
||||
|
||||
for (const [status, codes] of emitted) {
|
||||
const described = responses[String(status)]?.description ?? "";
|
||||
for (const code of codes) {
|
||||
expect(
|
||||
described,
|
||||
`${status} description does not mention \`${code}\``,
|
||||
).toContain(code);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes exactly the fields a real response carries", async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const schema = doc().components?.schemas?.["HealthScoreConfigResponse"];
|
||||
expect(schema, "HealthScoreConfigResponse is not emitted").toBeDefined();
|
||||
|
||||
expect(Object.keys(schema?.properties ?? {}).sort()).toEqual(
|
||||
Object.keys(env.data).sort(),
|
||||
);
|
||||
// `updatedAt` is absent on a never-configured GET, so it must not be
|
||||
// required — a generated decoder would reject that read.
|
||||
expect(schema?.required ?? []).not.toContain("updatedAt");
|
||||
});
|
||||
|
||||
it("leaves updatedAt off the read that genuinely has none", async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request(`http://localhost${PATH}`),
|
||||
);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
const published = Object.keys(
|
||||
doc().components?.schemas?.["HealthScoreConfigResponse"]?.properties ??
|
||||
{},
|
||||
);
|
||||
|
||||
expect(Object.keys(env.data)).not.toContain("updatedAt");
|
||||
for (const key of Object.keys(env.data)) expect(published).toContain(key);
|
||||
});
|
||||
});
|
||||
@@ -1,463 +0,0 @@
|
||||
/**
|
||||
* v1.18.0 — PATCH/GET /api/auth/me/modules.
|
||||
*
|
||||
* Covers the read projection, the field-by-field merge, core-module
|
||||
* refusal (strict schema), the cycle/coach delegation reflected in the
|
||||
* resolved map, and the standard auth / rate-limit / 422 envelope.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
cycleProfile: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
// Operator master flag = on by default; coach delegation keys off it.
|
||||
// Partial mock: the rest of the module stays real, and both switch readers
|
||||
// answer from the one the test drives.
|
||||
vi.mock("@/lib/feature-flags", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/feature-flags")>()),
|
||||
...(
|
||||
await import("@/__tests__/helpers/assistant-switches-mock")
|
||||
).mockAssistantSwitches(vi.fn().mockResolvedValue({ coach: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/cache/invalidate")>();
|
||||
return { ...actual, invalidateUserHealthScore: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
type ModuleMap = Record<string, boolean>;
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/modules", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Default DB shape: fresh user, no module prefs, no cycle row. The
|
||||
* `findUnique` mock reads from a mutable holder so a PATCH `update`
|
||||
* (which writes `modulePreferencesJson`) is reflected by the post-update
|
||||
* `resolveModuleMap` re-read — mirroring real DB round-trip semantics.
|
||||
*/
|
||||
function primeUser(
|
||||
over: {
|
||||
gender?: string | null;
|
||||
disableCoach?: boolean;
|
||||
modulePreferencesJson?: unknown;
|
||||
cycleTrackingEnabled?: boolean | null;
|
||||
} = {},
|
||||
) {
|
||||
const row: {
|
||||
gender: string | null;
|
||||
disableCoach: boolean;
|
||||
modulePreferencesJson: unknown;
|
||||
} = {
|
||||
gender: over.gender ?? null,
|
||||
disableCoach: over.disableCoach ?? false,
|
||||
modulePreferencesJson: over.modulePreferencesJson ?? null,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation((() =>
|
||||
Promise.resolve({ ...row })) as never);
|
||||
vi.mocked(prisma.user.update).mockImplementation(((args: {
|
||||
data?: { modulePreferencesJson?: unknown };
|
||||
}) => {
|
||||
const data = args.data;
|
||||
if (data && "modulePreferencesJson" in data) {
|
||||
row.modulePreferencesJson = data.modulePreferencesJson;
|
||||
}
|
||||
return Promise.resolve({});
|
||||
}) as never);
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(
|
||||
over.cycleTrackingEnabled === undefined
|
||||
? null
|
||||
: ({ cycleTrackingEnabled: over.cycleTrackingEnabled } as never),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never);
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/modules", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/modules"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns every toggleable module on for a fresh user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser();
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/modules"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { modules: ModuleMap } };
|
||||
expect(env.data.modules.mood).toBe(true);
|
||||
expect(env.data.modules.sleep).toBe(true);
|
||||
expect(env.data.modules.insights).toBe(true);
|
||||
// cycle gender-derived (null gender ⇒ off); coach delegated (on).
|
||||
expect(env.data.modules.cycle).toBe(false);
|
||||
expect(env.data.modules.coach).toBe(true);
|
||||
});
|
||||
|
||||
it("reflects a disabled module from the persisted allowlist", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({ modulePreferencesJson: { glucose: false } });
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/modules"),
|
||||
);
|
||||
const env = (await res.json()) as { data: { modules: ModuleMap } };
|
||||
expect(env.data.modules.glucose).toBe(false);
|
||||
expect(env.data.modules.mood).toBe(true);
|
||||
});
|
||||
|
||||
it("reflects coach delegation (operator master flag off)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({ coach: false } as never);
|
||||
primeUser();
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/modules"),
|
||||
);
|
||||
const env = (await res.json()) as { data: { modules: ModuleMap } };
|
||||
expect(env.data.modules.coach).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/modules", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ mood: false }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables a module and persists the merged allowlist", 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);
|
||||
const env = (await res.json()) as { data: { modules: ModuleMap } };
|
||||
expect(env.data.modules.mood).toBe(false);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
select: { updatedAt: true },
|
||||
data: { modulePreferencesJson: { mood: false } },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.modules.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({ changed: ["mood"] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges over the existing allowlist field-by-field (siblings intact)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// user previously disabled glucose; now disabling sleep too.
|
||||
primeUser({ modulePreferencesJson: { glucose: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ sleep: false }),
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
select: { updatedAt: true },
|
||||
data: { modulePreferencesJson: { glucose: false, sleep: false } },
|
||||
});
|
||||
});
|
||||
|
||||
it("can re-enable a previously disabled module", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({ modulePreferencesJson: { glucose: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ glucose: true }),
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
select: { updatedAt: true },
|
||||
data: { modulePreferencesJson: { glucose: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("REFUSES to disable a core module (strict schema 422)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser();
|
||||
|
||||
// v1.18.1 — medications graduated to a toggleable module, so only
|
||||
// weight / blood pressure / pulse stay refused as core domains.
|
||||
for (const core of ["weight", "bloodPressure", "pulse"]) {
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ [core]: false }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
}
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unknown module key with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser();
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ notAModule: false }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-boolean value with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser();
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ mood: "false" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 400, not the 422 this used to pin. A body that will not parse is a
|
||||
// client-side serialisation fault, and `safeJson` plus roughly two hundred
|
||||
// and twenty other routes have always answered 400 for it; the dotted token
|
||||
// moved to `meta.errorCode`, which is where a machine code belongs.
|
||||
it("rejects malformed JSON with 400", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const req = new Request("http://localhost/api/auth/me/modules", {
|
||||
method: "PATCH",
|
||||
body: "{ not valid json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: "Invalid JSON body",
|
||||
meta: { errorCode: "modules.body.invalid_json" },
|
||||
});
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate-limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ mood: false }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a previously-corrupted stored blob to clean booleans on merge", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// junk in the stored row must not leak into the persisted merge.
|
||||
primeUser({ modulePreferencesJson: { mood: "yes", sleep: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ labs: false }),
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
select: { updatedAt: true },
|
||||
data: { modulePreferencesJson: { sleep: false, labs: false } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.35.0 — four of the score's pillars come and go with a module
|
||||
* toggle, so a toggle changes what the composite is made of. The score
|
||||
* caches hold the old composition and are served for up to an hour, so
|
||||
* the person who just turned a module off keeps seeing a number
|
||||
* computed from it. Nothing about that is visible in a test that does
|
||||
* not assert the eviction, which is why it went unnoticed for so long.
|
||||
*/
|
||||
describe("PATCH /api/auth/me/modules — the score caches", () => {
|
||||
it("evicts the score caches after a module toggle lands", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({ modulePreferencesJson: null });
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ sleep: false }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("does not evict them when the write never landed", 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({ sleep: false, baseUpdatedAt: "2026-07-24T08:00:00.000Z" }),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.32.22 — 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();
|
||||
});
|
||||
});
|
||||
@@ -1,754 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: <T>(v: T) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/notification-prefs", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/notification-prefs"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the documented defaults for a fresh user (null row)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/notification-prefs"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { medication: { clientManaged: boolean } };
|
||||
};
|
||||
expect(env.data).toEqual({
|
||||
medication: {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
coach: {
|
||||
nudgesEnabled: true,
|
||||
nudgeMedication: true,
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the resolved prefs when the row holds a value", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: { medication: { clientManaged: true } },
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/notification-prefs"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { medication: { clientManaged: boolean } };
|
||||
};
|
||||
expect(env.data).toEqual({
|
||||
medication: {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
coach: {
|
||||
nudgesEnabled: true,
|
||||
nudgeMedication: true,
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns defaults when the persisted shape has drifted", async () => {
|
||||
// Forward-compat: an admin hand-edit / future-rename should not
|
||||
// crash the GET.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: { unknownCategory: { foo: "bar" } },
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/notification-prefs"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { medication: { clientManaged: boolean } };
|
||||
};
|
||||
expect(env.data).toEqual({
|
||||
medication: {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
coach: {
|
||||
nudgesEnabled: true,
|
||||
nudgeMedication: true,
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ medication: { clientManaged: true } }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flips medication.clientManaged on and writes the audit row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ medication: { clientManaged: true } }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { medication: { clientManaged: boolean } };
|
||||
};
|
||||
expect(env.data).toEqual({
|
||||
medication: {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
coach: {
|
||||
nudgesEnabled: true,
|
||||
nudgeMedication: true,
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
// v1.32.22 — 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: {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
coach: {
|
||||
nudgesEnabled: true,
|
||||
nudgeMedication: true,
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.notification-prefs.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: {
|
||||
medication: {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
coach: {
|
||||
nudgesEnabled: true,
|
||||
nudgeMedication: true,
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
},
|
||||
next: {
|
||||
medication: {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
coach: {
|
||||
nudgesEnabled: true,
|
||||
nudgeMedication: true,
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
},
|
||||
changed: ["medication"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// 400, not the 422 this used to pin. A body that will not parse is a
|
||||
// client-side serialisation fault, and `safeJson` plus roughly two hundred
|
||||
// and twenty other routes have always answered 400 for it; the dotted token
|
||||
// moved to `meta.errorCode`, which is where a machine code belongs.
|
||||
it("rejects malformed JSON with 400", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const req = new Request("http://localhost/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
body: "{ not valid json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: "Invalid JSON body",
|
||||
meta: { errorCode: "notification-prefs.body.invalid_json" },
|
||||
});
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-boolean clientManaged with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ medication: { clientManaged: "yes" } }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deep-merges over existing siblings without overwriting them", async () => {
|
||||
// Forward-compat: a row that already carries an unknown sibling
|
||||
// category (e.g. a future "mood" key persisted by a newer client)
|
||||
// must survive a PATCH that only touches `medication`. The route
|
||||
// parses with the current zod schema first, then deep-merges the
|
||||
// input over the parsed (defaulted) base. Today, "unknown sibling"
|
||||
// resolves back to defaults; the test pins that the medication
|
||||
// PATCH still lands and that future schema growth (adding new
|
||||
// siblings to the zod shape) will preserve them.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: { medication: { clientManaged: false } },
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ medication: { clientManaged: true } }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
select: { updatedAt: true },
|
||||
data: {
|
||||
notificationPrefs: {
|
||||
medication: {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
coach: {
|
||||
nudgesEnabled: true,
|
||||
nudgeMedication: true,
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the merged shape unchanged on an empty PATCH body", async () => {
|
||||
// Idempotent — an empty body keeps the row as-is and still writes
|
||||
// the audit trail (mirrors the disable-coach posture).
|
||||
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({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(mkPatch({}));
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { medication: { clientManaged: boolean } };
|
||||
};
|
||||
expect(env.data).toEqual({
|
||||
medication: {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
coach: {
|
||||
nudgesEnabled: true,
|
||||
nudgeMedication: true,
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
// v1.32.22 — 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: {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
coach: {
|
||||
nudgesEnabled: true,
|
||||
nudgeMedication: true,
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("v1.7.0 — persists a custom mood.reminderHour", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ mood: { reminderHour: 9 } }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { mood: { reminderHour: number } };
|
||||
};
|
||||
expect(env.data.mood).toEqual({ reminderHour: 9 });
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
select: { updatedAt: true },
|
||||
data: {
|
||||
notificationPrefs: {
|
||||
medication: {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 9 },
|
||||
cycle: { clientManaged: false },
|
||||
coach: {
|
||||
nudgesEnabled: true,
|
||||
nudgeMedication: true,
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("persists content.detail and keeps the siblings", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: { mood: { reminderHour: 7 } },
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ content: { detail: "detailed" } }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { content: { detail: string }; mood: { reminderHour: number } };
|
||||
};
|
||||
expect(env.data.content).toEqual({ detail: "detailed" });
|
||||
expect(env.data.mood).toEqual({ reminderHour: 7 });
|
||||
const written = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
|
||||
data: { notificationPrefs: { content: { detail: string } } };
|
||||
};
|
||||
expect(written.data.notificationPrefs.content).toEqual({
|
||||
detail: "detailed",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown content.detail with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ content: { detail: "everything" } }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("v1.7.0 — rejects a mood.reminderHour outside 0..23 with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ mood: { reminderHour: 24 } }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("v1.16.11 — round-trips a custom lowStockRunwayDays", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ medication: { lowStockRunwayDays: 14 } }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { medication: { lowStockRunwayDays: number | null } };
|
||||
};
|
||||
expect(env.data.medication.lowStockRunwayDays).toBe(14);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
select: { updatedAt: true },
|
||||
data: {
|
||||
notificationPrefs: expect.objectContaining({
|
||||
medication: {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 14,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("v1.16.11 — persists the explicit null (alert off)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: { medication: { lowStockRunwayDays: 14 } },
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ medication: { lowStockRunwayDays: null } }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { medication: { lowStockRunwayDays: number | null } };
|
||||
};
|
||||
expect(env.data.medication.lowStockRunwayDays).toBe(null);
|
||||
});
|
||||
|
||||
it("v1.16.11 — rejects a lowStockRunwayDays outside 1..60 with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
for (const value of [0, 61]) {
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ medication: { lowStockRunwayDays: value } }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
}
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate-limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ medication: { clientManaged: true } }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.32.22 — 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();
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/integrations/sources-in-use", () => ({
|
||||
getSourcesInUse: vi.fn().mockResolvedValue(["WITHINGS", "MANUAL"]),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("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";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
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/source-priority", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/source-priority", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns the resolved shape with defaults when the column is null", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: Record<string, unknown> };
|
||||
// Resolver always emits the keys the analytics layer needs.
|
||||
expect(env.data).toHaveProperty("weight");
|
||||
expect(env.data).toHaveProperty("metricPriority");
|
||||
expect(env.data).toHaveProperty("deviceTypePriority");
|
||||
});
|
||||
|
||||
it("carries the account's sources in use beside the ladders", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
const env = (await res.json()) as { data: { inUse: string[] } };
|
||||
expect(env.data.inUse).toEqual(["WITHINGS", "MANUAL"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/source-priority", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists the partial shape", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const body = { weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] };
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { sourcePriorityJson: body },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid shape with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ weight: "not-an-array" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── v1.4.25 W10 reconcile (security M-3) ──
|
||||
it("writes an audit-log entry with the previous and new shape", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const previousShape = {
|
||||
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: previousShape,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const body = { weight: ["APPLE_HEALTH", "WITHINGS", "MANUAL"] };
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
|
||||
expect(res.status).toBe(200);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.source-priority.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: previousShape,
|
||||
next: body,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
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(),
|
||||
},
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// The supplement nutrient rows are keyed by the same local day.
|
||||
const nutrientMock = vi.hoisted(() => vi.fn(async () => ({ queued: true })));
|
||||
vi.mock("@/lib/medications/ingredients/recompute-job", () => ({
|
||||
enqueueSupplementNutrientRecompute: nutrientMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
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 mkReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/me/timezone", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/timezone", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("accepts a valid IANA zone and writes it to the user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
timezone: "Pacific/Auckland",
|
||||
} as never);
|
||||
|
||||
const res = await PUT(mkReq({ timezone: "Pacific/Auckland" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { timezone: "Pacific/Auckland" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid timezone with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PUT(mkReq({ timezone: "Mars/Olympus" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty timezone with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PUT(mkReq({ timezone: "" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("rejects a missing timezone field with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PUT(mkReq({}));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
// The supplement nutrient rows' day keys were minted under the old zone;
|
||||
// without the enqueue nothing re-buckets them.
|
||||
it("enqueues the nutrient recompute when the zone actually changes", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await PUT(mkReq({ timezone: "America/New_York" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(nutrientMock).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("does not recompute when the zone is unchanged", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(nutrientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trims whitespace before validating", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
timezone: "Asia/Tokyo",
|
||||
} as never);
|
||||
const res = await PUT(mkReq({ timezone: " Asia/Tokyo " }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { timezone: "Asia/Tokyo" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/insights/unit-change-refresh", () => ({
|
||||
refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/unit-preference", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/unit-preference", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/unit-preference"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("defaults to metric for a fresh user (null column)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
unitPreference: null,
|
||||
} as never);
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/unit-preference"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { unitPreference: string };
|
||||
};
|
||||
expect(env.data.unitPreference).toBe("metric");
|
||||
});
|
||||
|
||||
it("defaults to metric when the column is missing (partial rollback)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({} as never);
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/unit-preference"),
|
||||
);
|
||||
const env = (await res.json()) as { data: { unitPreference: string } };
|
||||
expect(env.data.unitPreference).toBe("metric");
|
||||
});
|
||||
|
||||
it("returns imperial when set", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
unitPreference: "imperial",
|
||||
} as never);
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/unit-preference"),
|
||||
);
|
||||
const env = (await res.json()) as { data: { unitPreference: string } };
|
||||
expect(env.data.unitPreference).toBe("imperial");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/unit-preference", () => {
|
||||
it("sets imperial and writes the audit row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
unitPreference: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ unitPreference: "imperial" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { unitPreference: string } };
|
||||
expect(env.data.unitPreference).toBe("imperial");
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { unitPreference: "imperial" },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.unit-preference.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: "metric",
|
||||
next: "imperial",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unknown preference value with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ unitPreference: "furlongs" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The three cases below pin the capped `safeJson` read. Before it, the
|
||||
// handler called `request.json()` bare: malformed JSON came back 422 rather
|
||||
// than the 400 every sibling scalar answers, a wrong content type was parsed
|
||||
// rather than refused, and there was NO body-size limit — this was the one
|
||||
// endpoint in the family that would materialise whatever it was handed. The
|
||||
// malformed-JSON assertion used to say 422 and was changed deliberately: it
|
||||
// pinned the inconsistency rather than a contract, and the only caller
|
||||
// branches on `res.ok` alone.
|
||||
it("rejects malformed JSON with 400, like its sibling scalars", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const req = new Request("http://localhost/api/auth/me/unit-preference", {
|
||||
method: "PATCH",
|
||||
body: "{ not valid json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a body over the 1 KB cap with 413", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Valid JSON, valid enum value, and far too big: the cap has to be what
|
||||
// rejects it, not the schema.
|
||||
const req = new Request("http://localhost/api/auth/me/unit-preference", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
unitPreference: "imperial",
|
||||
padding: "x".repeat(2048),
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(413);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a non-JSON content type with 415", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const req = new Request("http://localhost/api/auth/me/unit-preference", {
|
||||
method: "PATCH",
|
||||
body: "unitPreference=imperial",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(415);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH re-warms the texts that quote the unit", () => {
|
||||
it("re-warms the stored notes when the unit actually changes", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
unitPreference: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ unitPreference: "imperial" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("leaves them alone when the same unit is saved again", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
unitPreference: "imperial",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ unitPreference: "imperial" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* The workout-route opt-in: off by default, a hard-set PATCH, audit-logged,
|
||||
* and a malformed body refused before anything is written.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/workout-routes", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("/api/auth/me/workout-routes", () => {
|
||||
it("reads off when the row carries no value", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({
|
||||
data: { storeWorkoutRoutes: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("sets the flag field by field and audits the change", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
storeWorkoutRoutes: false,
|
||||
} as never);
|
||||
const res = await PATCH(mkPatch({ storeWorkoutRoutes: true }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({
|
||||
data: { storeWorkoutRoutes: true },
|
||||
});
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { storeWorkoutRoutes: true },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.storeWorkoutRoutes.update",
|
||||
expect.objectContaining({
|
||||
details: { previous: false, next: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a malformed body with 422 and writes nothing", async () => {
|
||||
const res = await PATCH(mkPatch({ storeWorkoutRoutes: "yes" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an unauthenticated caller", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await PATCH(mkPatch({ storeWorkoutRoutes: true }));
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* `PUT /api/auth/me/ai-usage` — the account's own AI usage level, within the
|
||||
* operator's ceiling, against the real database and through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - a level within the ceiling is stored, audited, and answered as the
|
||||
* resolved state, with the per-option estimates scaled from the measured
|
||||
* daily tokens of the last week;
|
||||
* - a level above the ceiling is refused with 422 `ai.usage.aboveCeiling`
|
||||
* and the ceiling named, never capped silently, and accepted once the
|
||||
* operator raises the ceiling;
|
||||
* - `null` returns to the instance default; fewer than three measured days
|
||||
* give no estimate;
|
||||
* - a malformed body is 422 `ai.usage.invalid`; the 31st change in an hour
|
||||
* is 429 `ai.usage.rateLimited`; an anonymous caller is 401.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { PUT } from "@/app/api/auth/me/ai-usage/route";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/auth/me/ai-usage";
|
||||
|
||||
type UsageOption = {
|
||||
level: string;
|
||||
allowed: boolean;
|
||||
estimatedTokensPerDay: number | null;
|
||||
};
|
||||
type AiUsageState = {
|
||||
level: string;
|
||||
preference: string | null;
|
||||
defaultLevel: string;
|
||||
maxLevel: string;
|
||||
source: string;
|
||||
measuredTokensPerDay: number | null;
|
||||
measuredDays: number;
|
||||
options: UsageOption[];
|
||||
};
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
async function storedLevel(userId: string) {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { aiUsageLevel: true },
|
||||
});
|
||||
return row.aiUsageLevel;
|
||||
}
|
||||
|
||||
/** One `coach_usage` row per past day, yesterday first, in UTC. */
|
||||
async function seedDailyTokens(userId: string, perDay: number[]) {
|
||||
const today = userDayKey(new Date(), "UTC");
|
||||
await prisma.coachUsage.createMany({
|
||||
data: perDay.map((totalTokens, i) => ({
|
||||
userId,
|
||||
dateKey: shiftDateKey(today, -(i + 1)),
|
||||
totalTokens,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
describe("PUT /api/auth/me/ai-usage", () => {
|
||||
it("stores a level within the ceiling and answers the resolved state", async () => {
|
||||
const user = await makeUser({ timezone: "UTC" });
|
||||
await seedDailyTokens(user.id, [90_000, 110_000, 100_000]);
|
||||
|
||||
const res = await callRoute<AiUsageState>(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { level: "frugal" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedLevel(user.id)).toBe("FRUGAL");
|
||||
expect(res.data).toMatchObject({
|
||||
level: "frugal",
|
||||
preference: "frugal",
|
||||
defaultLevel: "standard",
|
||||
maxLevel: "standard",
|
||||
source: "user",
|
||||
measuredTokensPerDay: 100_000,
|
||||
measuredDays: 3,
|
||||
});
|
||||
expect(res.data.options).toEqual([
|
||||
{ level: "frugal", allowed: true, estimatedTokensPerDay: 100_000 },
|
||||
{ level: "standard", allowed: true, estimatedTokensPerDay: 200_000 },
|
||||
{ level: "full", allowed: false, estimatedTokensPerDay: 666_667 },
|
||||
]);
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: user.id, action: "user.aiUsage.update" },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
|
||||
previous: null,
|
||||
next: "frugal",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a level above the ceiling with the ceiling named", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { level: "full" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta).toMatchObject({
|
||||
errorCode: "ai.usage.aboveCeiling",
|
||||
maxLevel: "standard",
|
||||
});
|
||||
expect(await storedLevel(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts full once the operator allows it", async () => {
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: { id: "singleton", aiUsageMaxLevel: "FULL" },
|
||||
update: { aiUsageMaxLevel: "FULL" },
|
||||
});
|
||||
const user = await makeUser();
|
||||
const res = await callRoute<AiUsageState>(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { level: "full" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.level).toBe("full");
|
||||
expect(await storedLevel(user.id)).toBe("FULL");
|
||||
});
|
||||
|
||||
it("returns to the instance default on null", async () => {
|
||||
const user = await makeUser({ timezone: "UTC", aiUsageLevel: "FRUGAL" });
|
||||
// Two measured days: fewer than the three an estimate needs.
|
||||
await seedDailyTokens(user.id, [100_000, 100_000]);
|
||||
|
||||
const res = await callRoute<AiUsageState>(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { level: null },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedLevel(user.id)).toBeNull();
|
||||
expect(res.data).toMatchObject({
|
||||
level: "standard",
|
||||
preference: null,
|
||||
source: "default",
|
||||
});
|
||||
expect(res.data.options.map((o) => o.estimatedTokensPerDay)).toEqual([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ level: "lavish" }],
|
||||
[{}],
|
||||
[{ level: "frugal", extra: true }],
|
||||
[{ level: 1 }],
|
||||
])("refuses %j as invalid and stores nothing", async (body) => {
|
||||
const user = await makeUser({ aiUsageLevel: "STANDARD" });
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta?.errorCode).toBe("ai.usage.invalid");
|
||||
expect(await storedLevel(user.id)).toBe("STANDARD");
|
||||
});
|
||||
|
||||
it("allows 30 changes an hour and refuses the 31st", async () => {
|
||||
const user = await makeUser();
|
||||
const other = await makeUser();
|
||||
const session = await makeSession(user);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const ok = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { level: i % 2 === 0 ? "frugal" : "standard" },
|
||||
auth: session,
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
}
|
||||
const refused = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { level: "frugal" },
|
||||
auth: session,
|
||||
});
|
||||
expect(refused.status).toBe(429);
|
||||
expect(refused.meta?.errorCode).toBe("ai.usage.rateLimited");
|
||||
// The 30th change set standard; the refused one did not land.
|
||||
expect(await storedLevel(user.id)).toBe("STANDARD");
|
||||
|
||||
// The window is an hour: the account's bucket in Postgres resets about
|
||||
// sixty minutes after the first change, not after one.
|
||||
const [bucket] = await prisma.$queryRaw<Array<{ secondsLeft: number }>>`
|
||||
SELECT EXTRACT(EPOCH FROM reset_at - (NOW() AT TIME ZONE 'UTC'))::float8
|
||||
AS "secondsLeft"
|
||||
FROM rate_limits WHERE key = ${`ai-usage:${user.id}`}
|
||||
`;
|
||||
expect(bucket?.secondsLeft).toBeGreaterThan(55 * 60);
|
||||
expect(bucket?.secondsLeft).toBeLessThanOrEqual(60 * 60);
|
||||
|
||||
const neighbour = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { level: "frugal" },
|
||||
auth: await makeSession(other),
|
||||
});
|
||||
expect(neighbour.status).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { level: "frugal" },
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* `/api/auth/me/coach-prefs` — the Coach prompt-tuning preferences, against
|
||||
* the real database and through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - a saved account reads its prefs plus the `updatedAt` token; a
|
||||
* never-saved one reads the pure defaults and no token, because there is
|
||||
* nothing to guard yet;
|
||||
* - the PUT stores the defaulted form and mirrors `excludeMetrics` onto
|
||||
* `insightsExcludeMetrics` in the same write; with a base token the write
|
||||
* is conditional, and a stale token answers 409 and leaves both columns
|
||||
* as they were; without one the write is unconditional; a malformed token
|
||||
* is 422;
|
||||
* - the thinking depth is stored as chosen, and the wide event names it,
|
||||
* an absent level as the default; a level outside the set is 422.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { GET, PUT } from "@/app/api/auth/me/coach-prefs/route";
|
||||
import { readLogBuffer } from "@/lib/logging/in-memory-buffer";
|
||||
|
||||
import { anonymous, callRoute, type RouteResult } from "../../kit/call-route";
|
||||
import { makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/auth/me/coach-prefs";
|
||||
|
||||
type CoachPrefs = {
|
||||
tone: string;
|
||||
excludeMetrics: string[];
|
||||
reasoning?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
async function storedColumns(userId: string) {
|
||||
return prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
coachPrefsJson: true,
|
||||
insightsExcludeMetrics: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The `meta` the route put on its wide event, read from the log ring. */
|
||||
function putEventMeta(res: RouteResult): Record<string, unknown> | undefined {
|
||||
const requestId = res.headers.get("x-request-id");
|
||||
if (!requestId) throw new Error("the response carries no x-request-id");
|
||||
const [event] = readLogBuffer({
|
||||
requestId,
|
||||
action: "auth.me.coach-prefs.put",
|
||||
});
|
||||
return event?.meta;
|
||||
}
|
||||
|
||||
describe("GET /api/auth/me/coach-prefs", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("answers a saved account's prefs plus the optimistic-concurrency token", async () => {
|
||||
const user = await makeUser({ coachPrefsJson: { tone: "concise" } });
|
||||
const res = await callRoute<CoachPrefs>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.tone).toBe("concise");
|
||||
const row = await storedColumns(user.id);
|
||||
expect(res.data.updatedAt).toBe(row.updatedAt.toISOString());
|
||||
});
|
||||
|
||||
it("answers the pure defaults with no token for a never-saved account", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute<CoachPrefs>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.tone).toBe("warm");
|
||||
expect(res.data.excludeMetrics).toEqual([]);
|
||||
expect(res.data).not.toHaveProperty("updatedAt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => {
|
||||
it("writes both columns under a current token and echoes the fresh one", async () => {
|
||||
const user = await makeUser({ coachPrefsJson: { tone: "neutral" } });
|
||||
const session = await makeSession(user);
|
||||
const read = await callRoute<CoachPrefs>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: session,
|
||||
});
|
||||
|
||||
const res = await callRoute<CoachPrefs>(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { excludeMetrics: ["weight"], baseUpdatedAt: read.data.updatedAt },
|
||||
auth: session,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = await storedColumns(user.id);
|
||||
expect(row.coachPrefsJson).toMatchObject({
|
||||
tone: "warm",
|
||||
excludeMetrics: ["weight"],
|
||||
});
|
||||
expect(row.insightsExcludeMetrics).toEqual(["weight"]);
|
||||
expect(res.data.updatedAt).toBe(row.updatedAt.toISOString());
|
||||
expect(res.data.updatedAt).not.toBe(read.data.updatedAt);
|
||||
});
|
||||
|
||||
it("answers a stale token with 409 and leaves both columns untouched", async () => {
|
||||
const user = await makeUser({ coachPrefsJson: { tone: "neutral" } });
|
||||
const session = await makeSession(user);
|
||||
const read = await callRoute<CoachPrefs>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: session,
|
||||
});
|
||||
|
||||
// Another device saves first, with the same token.
|
||||
const first = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { excludeMetrics: ["mood"], baseUpdatedAt: read.data.updatedAt },
|
||||
auth: session,
|
||||
});
|
||||
expect(first.status).toBe(200);
|
||||
const afterFirst = await storedColumns(user.id);
|
||||
|
||||
const stale = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { excludeMetrics: ["weight"], baseUpdatedAt: read.data.updatedAt },
|
||||
auth: session,
|
||||
});
|
||||
expect(stale.status).toBe(409);
|
||||
expect(stale.meta?.errorCode).toBe("coach_prefs_conflict");
|
||||
expect(await storedColumns(user.id)).toEqual(afterFirst);
|
||||
expect(afterFirst.insightsExcludeMetrics).toEqual(["mood"]);
|
||||
});
|
||||
|
||||
it("writes unconditionally when the token is omitted", async () => {
|
||||
const user = await makeUser({ coachPrefsJson: { tone: "neutral" } });
|
||||
const res = await callRoute<CoachPrefs>(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { excludeMetrics: ["weight"] },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const row = await storedColumns(user.id);
|
||||
expect(row.insightsExcludeMetrics).toEqual(["weight"]);
|
||||
expect(row.coachPrefsJson).toMatchObject({ excludeMetrics: ["weight"] });
|
||||
expect(res.data.updatedAt).toBe(row.updatedAt.toISOString());
|
||||
});
|
||||
|
||||
it("answers a malformed token with 422 and leaves the row alone", async () => {
|
||||
const user = await makeUser();
|
||||
const before = await storedColumns(user.id);
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { excludeMetrics: ["weight"], baseUpdatedAt: "not-a-date" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta?.errorCode).toBe("invalid_base_updated_at");
|
||||
expect(await storedColumns(user.id)).toEqual(before);
|
||||
});
|
||||
|
||||
it("answers a body that is not JSON with 400 and its error code", async () => {
|
||||
const user = await makeUser();
|
||||
// The kit serialises every body as JSON, so the unparseable body here is
|
||||
// the empty one, sent with a JSON content type.
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
headers: { "content-type": "application/json" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.meta?.errorCode).toBe("coach-prefs.body.invalid_json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/coach-prefs — thinking depth", () => {
|
||||
it("stores the chosen level and names it on the wide event", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute<CoachPrefs>(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { reasoning: "high" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.reasoning).toBe("high");
|
||||
const row = await storedColumns(user.id);
|
||||
expect(row.coachPrefsJson).toMatchObject({ reasoning: "high" });
|
||||
expect(putEventMeta(res)).toMatchObject({ reasoning: "high" });
|
||||
});
|
||||
|
||||
it("reads an absent level as the default on the wide event", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { tone: "warm" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(putEventMeta(res)).toMatchObject({ reasoning: "medium" });
|
||||
});
|
||||
|
||||
it.each(["xhigh", "none", 2, null])(
|
||||
"refuses %s as a level with 422 and stores nothing",
|
||||
async (value) => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { reasoning: value },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta?.errorCode).toBe("coach-prefs.body.invalid_shape");
|
||||
const row = await storedColumns(user.id);
|
||||
expect(row.coachPrefsJson).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* `/api/auth/me/diabetes` — the declared diabetes flag, against the real
|
||||
* database and through the real API kit.
|
||||
*
|
||||
* The flag switches every glucose surface from the general normal bands to
|
||||
* the glycemic goal bands and reaches the Coach snapshot; the web toggle in
|
||||
* the thresholds section rides this route. Pinned here:
|
||||
* - the read answers false for an account that never declared it and true
|
||||
* once it is stored;
|
||||
* - the write stores the flag both ways, answers it and audits previous and
|
||||
* next; a missing, non-boolean or unknown field is 422 and leaves the
|
||||
* column alone;
|
||||
* - the write bucket allows 60 a minute per account.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { GET, PATCH } from "@/app/api/auth/me/diabetes/route";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/auth/me/diabetes";
|
||||
|
||||
type Diabetes = { hasDiabetes: boolean };
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
async function storedFlag(userId: string): Promise<boolean> {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { hasDiabetes: true },
|
||||
});
|
||||
return row.hasDiabetes;
|
||||
}
|
||||
|
||||
describe("GET /api/auth/me/diabetes", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
|
||||
it("answers false for an account that never declared the flag", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute<Diabetes>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ hasDiabetes: false });
|
||||
});
|
||||
|
||||
it("answers true once the flag is stored", async () => {
|
||||
const user = await makeUser({ hasDiabetes: true });
|
||||
const res = await callRoute<Diabetes>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ hasDiabetes: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/diabetes", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { hasDiabetes: true },
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
|
||||
it("turns the flag on, answers it, audits the transition and reads back", async () => {
|
||||
const user = await makeUser();
|
||||
const session = await makeSession(user);
|
||||
const res = await callRoute<Diabetes>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { hasDiabetes: true },
|
||||
auth: session,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ hasDiabetes: true });
|
||||
expect(await storedFlag(user.id)).toBe(true);
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: user.id, action: "user.diabetes.update" },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
|
||||
previous: false,
|
||||
next: true,
|
||||
});
|
||||
|
||||
const read = await callRoute<Diabetes>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: session,
|
||||
});
|
||||
expect(read.data).toEqual({ hasDiabetes: true });
|
||||
});
|
||||
|
||||
it("turns the flag off and answers the resolved state", async () => {
|
||||
const user = await makeUser({ hasDiabetes: true });
|
||||
const res = await callRoute<Diabetes>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { hasDiabetes: false },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ hasDiabetes: false });
|
||||
expect(await storedFlag(user.id)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a missing hasDiabetes field", { otherField: true }],
|
||||
["a non-boolean hasDiabetes", { hasDiabetes: "true" }],
|
||||
["an undeclared key beside it", { hasDiabetes: true, extra: 1 }],
|
||||
])("refuses %s with 422 and stores nothing", async (_, body) => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(await storedFlag(user.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
|
||||
const user = await makeUser();
|
||||
const other = await makeUser();
|
||||
const session = await makeSession(user);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const ok = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { hasDiabetes: i % 2 === 0 },
|
||||
auth: session,
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
}
|
||||
const refused = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { hasDiabetes: true },
|
||||
auth: session,
|
||||
});
|
||||
expect(refused.status).toBe(429);
|
||||
// The 60th write set false; the refused one did not land.
|
||||
expect(await storedFlag(user.id)).toBe(false);
|
||||
|
||||
const neighbour = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { hasDiabetes: true },
|
||||
auth: await makeSession(other),
|
||||
});
|
||||
expect(neighbour.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* `/api/auth/me/disable-coach` — the per-account Coach opt-out, against the
|
||||
* real database and through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - the read answers the stored flag, false on a fresh account;
|
||||
* - the write stores the flag, answers the next state, and audits it under
|
||||
* an enable or disable action with previous and next, also when the value
|
||||
* does not change (the audit row mirrors the call);
|
||||
* - a missing or non-boolean field, an undeclared key beside a valid one,
|
||||
* and a body that is not JSON are refused without touching the row; the
|
||||
* 422 names the undeclared key without reflecting a hostile one verbatim;
|
||||
* - the write bucket allows 60 a minute per account.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { GET, PATCH } from "@/app/api/auth/me/disable-coach/route";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/auth/me/disable-coach";
|
||||
|
||||
type DisableCoach = { disableCoach: boolean };
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
async function storedFlag(userId: string): Promise<boolean> {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { disableCoach: true },
|
||||
});
|
||||
return row.disableCoach;
|
||||
}
|
||||
|
||||
async function auditRows(userId: string) {
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: { userId, action: { startsWith: "user.disable-coach." } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
action: r.action,
|
||||
details: JSON.parse(r.details ?? "null"),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("GET /api/auth/me/disable-coach", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
|
||||
it("answers false for a fresh account", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute<DisableCoach>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ disableCoach: false });
|
||||
});
|
||||
|
||||
it("answers true for an account that opted out", async () => {
|
||||
const user = await makeUser({ disableCoach: true });
|
||||
const res = await callRoute<DisableCoach>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ disableCoach: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/disable-coach", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { disableCoach: true },
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("turns the flag on and audits the enable", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute<DisableCoach>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { disableCoach: true },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ disableCoach: true });
|
||||
expect(await storedFlag(user.id)).toBe(true);
|
||||
expect(await auditRows(user.id)).toEqual([
|
||||
{
|
||||
action: "user.disable-coach.enable",
|
||||
details: { previous: false, next: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("turns the flag off and audits the disable", async () => {
|
||||
const user = await makeUser({ disableCoach: true });
|
||||
const res = await callRoute<DisableCoach>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { disableCoach: false },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ disableCoach: false });
|
||||
expect(await storedFlag(user.id)).toBe(false);
|
||||
expect(await auditRows(user.id)).toEqual([
|
||||
{
|
||||
action: "user.disable-coach.disable",
|
||||
details: { previous: true, next: false },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes through and audits when the flag already holds the value", async () => {
|
||||
const user = await makeUser({ disableCoach: true });
|
||||
const res = await callRoute<DisableCoach>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { disableCoach: true },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedFlag(user.id)).toBe(true);
|
||||
expect(await auditRows(user.id)).toEqual([
|
||||
{
|
||||
action: "user.disable-coach.enable",
|
||||
details: { previous: true, next: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a missing field", { otherField: "ignored" }],
|
||||
["a non-boolean field", { disableCoach: "true" }],
|
||||
])("refuses %s with 422 and stores nothing", async (_label, body) => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(await storedFlag(user.id)).toBe(false);
|
||||
expect(await auditRows(user.id)).toEqual([]);
|
||||
});
|
||||
|
||||
// 400 with a machine code in `meta.errorCode`, like every route that reads
|
||||
// through `safeJson`; this one used to answer 422.
|
||||
it("refuses a body that is not JSON with 400 and its error code", async () => {
|
||||
const user = await makeUser();
|
||||
// The kit serialises every body as JSON, so the unparseable body here is
|
||||
// the empty one, sent with a JSON content type.
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
headers: { "content-type": "application/json" },
|
||||
auth: await makeSession(user),
|
||||
contract: {
|
||||
skip: "the registry declares no 400 for this PATCH, which answers one for a body that is not JSON",
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.error).toBe("Invalid JSON body");
|
||||
expect(res.meta?.errorCode).toBe("disable-coach.body.invalid_json");
|
||||
expect(await storedFlag(user.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
|
||||
const user = await makeUser();
|
||||
const other = await makeUser();
|
||||
const session = await makeSession(user);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const ok = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { disableCoach: i % 2 === 0 },
|
||||
auth: session,
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
}
|
||||
const refused = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { disableCoach: true },
|
||||
auth: session,
|
||||
});
|
||||
expect(refused.status).toBe(429);
|
||||
// The 60th write turned it off; the refused one did not land.
|
||||
expect(await storedFlag(user.id)).toBe(false);
|
||||
expect(await auditRows(user.id)).toHaveLength(60);
|
||||
|
||||
const neighbour = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { disableCoach: true },
|
||||
auth: await makeSession(other),
|
||||
});
|
||||
expect(neighbour.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The body schema is `.strict()`: a key the route does not declare is refused
|
||||
* rather than trimmed away. The body worth pinning is the one that used to
|
||||
* succeed while doing something other than what it said:
|
||||
* `{ disableCoach: true, disableCoachh: false }` wrote `true` and answered 200.
|
||||
*/
|
||||
describe("PATCH /api/auth/me/disable-coach — unknown keys", () => {
|
||||
it("refuses an undeclared key beside a valid one and writes nothing", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { disableCoach: true, disableCoachh: false },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(await storedFlag(user.id)).toBe(false);
|
||||
expect(await auditRows(user.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("names the offending key in the 422", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { disableCoach: true, disableCoachh: false },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body).toMatchObject({
|
||||
details: {
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "unrecognized_keys",
|
||||
keys: ["disableCoachh"],
|
||||
message: expect.stringContaining("disableCoachh"),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reflect a hostile key name back verbatim", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { disableCoach: true, "<script>alert(1)</script>": 1 },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
const raw = JSON.stringify(res.body);
|
||||
expect(raw).not.toContain("<script>");
|
||||
expect(raw).toContain("script_alert_1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* `/api/auth/me/documents-auto-ai-read` — the opt-in to read documents with
|
||||
* AI on upload, against the real database, the real capability resolver and
|
||||
* a real pg-boss queue.
|
||||
*
|
||||
* Turning the switch on is the standing consent act, so the write mints an
|
||||
* `ai_extraction` receipt (document reading only, never the master `ai_full`
|
||||
* that would also open the Coach and the AI analysis). And because the
|
||||
* summary job is enqueued at upload time and does nothing while the switch is
|
||||
* off, a genuine off-to-on flip schedules one catch-up pass over the vault;
|
||||
* without it a person who filled the vault first saw the switch do nothing.
|
||||
* Pinned here:
|
||||
* - the flip stores the flag, mints exactly one extraction receipt and
|
||||
* queues exactly one catch-up, and the capability is read after the mint,
|
||||
* so the receipt the same request wrote opens it;
|
||||
* - re-saving on, or turning it off, queues nothing, and off mints nothing;
|
||||
* - an active receipt that already covers document reads is not doubled;
|
||||
* - a receipt minted concurrently by another request is a success, not a
|
||||
* 500;
|
||||
* - with document reading closed by the operator the flag is still saved
|
||||
* and no catch-up is queued.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { PgBoss } from "pg-boss";
|
||||
|
||||
import type { Prisma, User } from "@/generated/prisma/client";
|
||||
import { GET, PATCH } from "@/app/api/auth/me/documents-auto-ai-read/route";
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
DOCUMENT_SUMMARY_CATCHUP_QUEUE,
|
||||
enqueueSummaryCatchUp,
|
||||
} from "@/lib/jobs/document-summary-catchup";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeSession, makeUser, uniqueSuffix } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/auth/me/documents-auto-ai-read";
|
||||
|
||||
type AutoRead = { documentsAutoAiRead: boolean };
|
||||
|
||||
let boss: PgBoss;
|
||||
|
||||
beforeAll(async () => {
|
||||
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
|
||||
await boss.start();
|
||||
await boss.createQueue(DOCUMENT_SUMMARY_CATCHUP_QUEUE);
|
||||
setGlobalBoss(boss);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
setGlobalBoss(null);
|
||||
await boss?.stop({ graceful: false });
|
||||
});
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
/**
|
||||
* An account with its own provider key, so the only thing between it and
|
||||
* document reading is the consent receipt the switch mints.
|
||||
*/
|
||||
async function makeReader(
|
||||
overrides: Partial<Prisma.UserUncheckedCreateInput> = {},
|
||||
): Promise<User> {
|
||||
return makeUser({
|
||||
aiProvider: "ANTHROPIC",
|
||||
aiModel: "claude-sonnet-4-6",
|
||||
// Presence only: the capability probe never decrypts it.
|
||||
aiAnthropicKeyEncrypted: "v1:presence-only",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function catchUpJobs(userId: string): Promise<number> {
|
||||
const rows = await prisma.$queryRaw<Array<{ n: number }>>`
|
||||
SELECT count(*)::int AS n FROM pgboss.job
|
||||
WHERE name = ${DOCUMENT_SUMMARY_CATCHUP_QUEUE}
|
||||
AND singleton_key = ${`document-summary-catchup|${userId}`}
|
||||
`;
|
||||
return rows[0]?.n ?? 0;
|
||||
}
|
||||
|
||||
/** The catch-up is fire-and-forget; wait for its job to land on the queue. */
|
||||
async function waitForCatchUp(userId: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
if ((await catchUpJobs(userId)) > 0) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`no catch-up job for ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Before asserting that a write queued nothing: send a catch-up of our own
|
||||
* through the same queue and let it land, so a send the route had started
|
||||
* before returning has had the same time to land too.
|
||||
*/
|
||||
async function settleQueue(): Promise<void> {
|
||||
const probe = `probe-${uniqueSuffix()}`;
|
||||
await enqueueSummaryCatchUp(probe);
|
||||
await waitForCatchUp(probe);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
async function storedFlag(userId: string): Promise<boolean> {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { documentsAutoAiRead: true },
|
||||
});
|
||||
return row.documentsAutoAiRead;
|
||||
}
|
||||
|
||||
async function activeReceipts(userId: string): Promise<string[]> {
|
||||
const rows = await prisma.consentReceipt.findMany({
|
||||
where: { userId, revokedAt: null },
|
||||
select: { kind: true },
|
||||
orderBy: { kind: "asc" },
|
||||
});
|
||||
return rows.map((r) => r.kind);
|
||||
}
|
||||
|
||||
async function auditDetails(userId: string): Promise<unknown[]> {
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: { userId, action: "user.documentsAutoAiRead.update" },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return rows.map((r) => JSON.parse(r.details ?? "null"));
|
||||
}
|
||||
|
||||
function patch(user: Pick<User, "id">, documentsAutoAiRead: boolean) {
|
||||
return makeSession(user).then((auth) =>
|
||||
callRoute<AutoRead>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { documentsAutoAiRead },
|
||||
auth,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("GET /api/auth/me/documents-auto-ai-read", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
|
||||
it("answers off for a fresh account and on once it is stored", async () => {
|
||||
const fresh = await makeUser();
|
||||
const opted = await makeUser({ documentsAutoAiRead: true });
|
||||
const off = await callRoute<AutoRead>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(fresh),
|
||||
});
|
||||
const on = await callRoute<AutoRead>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(opted),
|
||||
});
|
||||
expect(off.data).toEqual({ documentsAutoAiRead: false });
|
||||
expect(on.data).toEqual({ documentsAutoAiRead: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/documents-auto-ai-read", () => {
|
||||
it("on an off-to-on flip stores the flag, mints the extraction receipt and queues one catch-up", async () => {
|
||||
const user = await makeReader();
|
||||
// Without a receipt, document reading is closed for this account: the
|
||||
// catch-up below can only be queued because the capability is read after
|
||||
// the receipt this request mints.
|
||||
expect((await aiCapabilityForJob(user.id, "documentAi")).available).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const res = await patch(user, true);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ documentsAutoAiRead: true });
|
||||
expect(await storedFlag(user.id)).toBe(true);
|
||||
expect(await activeReceipts(user.id)).toEqual(["ai_extraction"]);
|
||||
await waitForCatchUp(user.id);
|
||||
await settleQueue();
|
||||
expect(await catchUpJobs(user.id)).toBe(1);
|
||||
expect(await auditDetails(user.id)).toEqual([
|
||||
{ previous: false, next: true, consentMinted: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("queues no second catch-up when the switch was already on", async () => {
|
||||
// Re-saving an already-on setting must not queue a second pass over the
|
||||
// same documents.
|
||||
const user = await makeReader({ documentsAutoAiRead: true });
|
||||
await prisma.consentReceipt.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "ai_extraction",
|
||||
artefact: "earlier flip",
|
||||
signedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await patch(user, true);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await settleQueue();
|
||||
expect(await catchUpJobs(user.id)).toBe(0);
|
||||
expect(await activeReceipts(user.id)).toEqual(["ai_extraction"]);
|
||||
});
|
||||
|
||||
it("turning it off stores off, queues nothing and mints nothing", async () => {
|
||||
const user = await makeReader({ documentsAutoAiRead: true });
|
||||
|
||||
const res = await patch(user, false);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ documentsAutoAiRead: false });
|
||||
expect(await storedFlag(user.id)).toBe(false);
|
||||
await settleQueue();
|
||||
expect(await catchUpJobs(user.id)).toBe(0);
|
||||
expect(await activeReceipts(user.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["ai_extraction", "ai_full"])(
|
||||
"mints no second receipt when an active %s already covers document reads",
|
||||
async (kind) => {
|
||||
const user = await makeReader();
|
||||
await prisma.consentReceipt.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind,
|
||||
artefact: "earlier grant",
|
||||
signedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await patch(user, true);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await activeReceipts(user.id)).toEqual([kind]);
|
||||
expect(await auditDetails(user.id)).toEqual([
|
||||
{ previous: false, next: true, consentMinted: false },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("mints a fresh receipt when the earlier one was revoked", async () => {
|
||||
const user = await makeReader();
|
||||
await prisma.consentReceipt.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "ai_extraction",
|
||||
artefact: "earlier grant",
|
||||
signedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
revokedAt: new Date("2026-02-01T00:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await patch(user, true);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await activeReceipts(user.id)).toEqual(["ai_extraction"]);
|
||||
});
|
||||
|
||||
it("treats a receipt minted concurrently by another request as success", async () => {
|
||||
// Another request inserts the receipt and has not committed yet: this
|
||||
// request's lookup cannot see it, its insert waits on the active-receipt
|
||||
// unique index, and fails once the other commits.
|
||||
const user = await makeReader();
|
||||
const session = await makeSession(user);
|
||||
// Wrapped in an object so the transaction does not wait on the request,
|
||||
// which itself waits on the transaction's commit.
|
||||
const { pending } = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.consentReceipt.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "ai_extraction",
|
||||
artefact: "concurrent flip",
|
||||
signedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const request = callRoute<AutoRead>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { documentsAutoAiRead: true },
|
||||
auth: session,
|
||||
});
|
||||
const deadline = Date.now() + 10_000;
|
||||
for (;;) {
|
||||
const waiting = await prisma.$queryRaw<Array<{ n: number }>>`
|
||||
SELECT count(*)::int AS n FROM pg_stat_activity
|
||||
WHERE wait_event_type = 'Lock'
|
||||
AND query ILIKE '%INSERT INTO%consent_receipts%'
|
||||
`;
|
||||
if ((waiting[0]?.n ?? 0) > 0) break;
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error("the route's receipt insert never waited");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
return { pending: request };
|
||||
},
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
const res = await pending;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ documentsAutoAiRead: true });
|
||||
expect(await storedFlag(user.id)).toBe(true);
|
||||
expect(await activeReceipts(user.id)).toEqual(["ai_extraction"]);
|
||||
expect(await auditDetails(user.id)).toEqual([
|
||||
{ previous: false, next: true, consentMinted: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("saves the flag but queues no catch-up while the operator has document reading closed", async () => {
|
||||
// Every summary the pass would enqueue could only be refused, so the pass
|
||||
// is not queued at all.
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: { id: "singleton", assistantDocumentAiEnabled: false },
|
||||
update: { assistantDocumentAiEnabled: false },
|
||||
});
|
||||
const user = await makeReader();
|
||||
|
||||
const res = await patch(user, true);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedFlag(user.id)).toBe(true);
|
||||
await settleQueue();
|
||||
expect(await catchUpJobs(user.id)).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a non-boolean flag with 422 and stores nothing", async () => {
|
||||
const user = await makeReader();
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { documentsAutoAiRead: "yes" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(await storedFlag(user.id)).toBe(false);
|
||||
expect(await activeReceipts(user.id)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* `/api/auth/me/glucose-unit` — the blood-glucose display unit, against the
|
||||
* real database and through the real API kit.
|
||||
*
|
||||
* `User.glucoseUnit` is read by about thirty surfaces; this route is its one
|
||||
* writer. Pinned here:
|
||||
* - the read resolves an account that never chose to mg/dL and reads back
|
||||
* a chosen mmol/L;
|
||||
* - the write stores the unit, answers it and audits previous and next;
|
||||
* a unit outside the two the app renders, or a missing field, is 422 and
|
||||
* leaves the column alone, because a stray string there would resolve to
|
||||
* mg/dL and quietly put an mmol/L reader back on the wrong number;
|
||||
* - a write that changes the unit re-warms the stored texts that quote it
|
||||
* (here: the cached daily briefing, a force-warm job on the real queue),
|
||||
* and saving the same unit again does not;
|
||||
* - the write bucket allows 60 a minute per account.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { PgBoss } from "pg-boss";
|
||||
|
||||
import { GET, PATCH } from "@/app/api/auth/me/glucose-unit/route";
|
||||
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { INSIGHT_PREGENERATE_QUEUE } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/auth/me/glucose-unit";
|
||||
|
||||
type GlucoseUnit = { glucoseUnit: "mg/dL" | "mmol/L" };
|
||||
|
||||
let boss: PgBoss;
|
||||
|
||||
beforeAll(async () => {
|
||||
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
|
||||
await boss.start();
|
||||
await boss.createQueue(INSIGHT_PREGENERATE_QUEUE);
|
||||
setGlobalBoss(boss);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
setGlobalBoss(null);
|
||||
await boss?.stop({ graceful: false });
|
||||
});
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
async function forceWarmJobs(userId: string): Promise<number> {
|
||||
const rows = await prisma.$queryRaw<Array<{ n: number }>>`
|
||||
SELECT count(*)::int AS n FROM pgboss.job
|
||||
WHERE name = ${INSIGHT_PREGENERATE_QUEUE}
|
||||
AND singleton_key = ${`force:${userId}`}
|
||||
`;
|
||||
return rows[0]?.n ?? 0;
|
||||
}
|
||||
|
||||
/** The re-warm is fire-and-forget; wait for its job to land on the queue. */
|
||||
async function waitForForceWarm(userId: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
if ((await forceWarmJobs(userId)) > 0) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`no force-warm job for ${userId}`);
|
||||
}
|
||||
|
||||
async function storedUnit(userId: string): Promise<string | null> {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { glucoseUnit: true },
|
||||
});
|
||||
return row.glucoseUnit;
|
||||
}
|
||||
|
||||
describe("GET /api/auth/me/glucose-unit", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
|
||||
it("resolves an account that never chose to mg/dL", async () => {
|
||||
const user = await makeUser({ glucoseUnit: null });
|
||||
const res = await callRoute<GlucoseUnit>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ glucoseUnit: "mg/dL" });
|
||||
});
|
||||
|
||||
it("reads back the unit the account chose", async () => {
|
||||
const user = await makeUser({ glucoseUnit: "mmol/L" });
|
||||
const res = await callRoute<GlucoseUnit>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.data).toEqual({ glucoseUnit: "mmol/L" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/glucose-unit", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { glucoseUnit: "mmol/L" },
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
|
||||
it("moves an account to mmol/L, answers it and audits the transition", async () => {
|
||||
const user = await makeUser({ glucoseUnit: null });
|
||||
const session = await makeSession(user);
|
||||
const res = await callRoute<GlucoseUnit>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { glucoseUnit: "mmol/L" },
|
||||
auth: session,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ glucoseUnit: "mmol/L" });
|
||||
expect(await storedUnit(user.id)).toBe("mmol/L");
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: user.id, action: "user.glucose-unit.update" },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
|
||||
previous: "mg/dL",
|
||||
next: "mmol/L",
|
||||
});
|
||||
|
||||
const read = await callRoute<GlucoseUnit>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: session,
|
||||
});
|
||||
expect(read.data).toEqual({ glucoseUnit: "mmol/L" });
|
||||
});
|
||||
|
||||
it("moves an account back to mg/dL", async () => {
|
||||
const user = await makeUser({ glucoseUnit: "mmol/L" });
|
||||
const res = await callRoute<GlucoseUnit>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { glucoseUnit: "mg/dL" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ glucoseUnit: "mg/dL" });
|
||||
expect(await storedUnit(user.id)).toBe("mg/dL");
|
||||
});
|
||||
|
||||
it("refuses a unit outside the two the app renders and stores nothing", async () => {
|
||||
const user = await makeUser({ glucoseUnit: "mmol/L" });
|
||||
const session = await makeSession(user);
|
||||
for (const bad of ["mmol/l", "mgdl", "imperial", "", 1]) {
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { glucoseUnit: bad },
|
||||
auth: session,
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
}
|
||||
expect(await storedUnit(user.id)).toBe("mmol/L");
|
||||
});
|
||||
|
||||
it("refuses a body without the glucoseUnit field with 422", async () => {
|
||||
const user = await makeUser({ glucoseUnit: null });
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { otherField: "mmol/L" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(await storedUnit(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
|
||||
const user = await makeUser();
|
||||
const other = await makeUser();
|
||||
const session = await makeSession(user);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const ok = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { glucoseUnit: i % 2 === 0 ? "mmol/L" : "mg/dL" },
|
||||
auth: session,
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
}
|
||||
const refused = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { glucoseUnit: "mmol/L" },
|
||||
auth: session,
|
||||
});
|
||||
expect(refused.status).toBe(429);
|
||||
expect(refused.headers.get("retry-after")).not.toBeNull();
|
||||
// The 60th write set mg/dL; the refused one did not land.
|
||||
expect(await storedUnit(user.id)).toBe("mg/dL");
|
||||
|
||||
const neighbour = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { glucoseUnit: "mmol/L" },
|
||||
auth: await makeSession(other),
|
||||
});
|
||||
expect(neighbour.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH re-warms the texts that quote the unit", () => {
|
||||
it("queues a briefing re-warm when the unit actually changes", async () => {
|
||||
const user = await makeUser({
|
||||
glucoseUnit: null,
|
||||
insightsCachedAt: new Date(),
|
||||
});
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { glucoseUnit: "mmol/L" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await waitForForceWarm(user.id);
|
||||
});
|
||||
|
||||
it("queues nothing when the same unit is saved again", async () => {
|
||||
const unchanged = await makeUser({
|
||||
glucoseUnit: "mmol/L",
|
||||
insightsCachedAt: new Date(),
|
||||
});
|
||||
const changed = await makeUser({
|
||||
glucoseUnit: "mg/dL",
|
||||
insightsCachedAt: new Date(),
|
||||
});
|
||||
|
||||
const same = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { glucoseUnit: "mmol/L" },
|
||||
auth: await makeSession(unchanged),
|
||||
});
|
||||
expect(same.status).toBe(200);
|
||||
|
||||
// A second account that does change its unit, sent after the first: once
|
||||
// its job is on the queue, a re-warm the first write had started would
|
||||
// have landed as well.
|
||||
await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { glucoseUnit: "mmol/L" },
|
||||
auth: await makeSession(changed),
|
||||
});
|
||||
await waitForForceWarm(changed.id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(await forceWarmJobs(unchanged.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* `/api/auth/me/health-score-config` — which pillars count toward the Health
|
||||
* Score, against the real database and through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - the read answers every pillar counting and no selection for a fresh
|
||||
* account (and no concurrency token), and a stored recipe as the pillars
|
||||
* it leaves counting, with the row's token;
|
||||
* - the write takes the positive selection, stores its complement with a
|
||||
* recipe version that rises by one on every accepted write, keeps a
|
||||
* keep-everything selection as an authored choice, audits it, and evicts
|
||||
* the account's cached score;
|
||||
* - since v1.38 the only selection refused for breadth is the empty one;
|
||||
* two areas, one area and one pillar all save;
|
||||
* - an unknown pillar, an unknown key, a `userId`, a body that is not JSON
|
||||
* and a stale base token are refused without a write or an eviction;
|
||||
* - the write bucket allows 60 a minute per account;
|
||||
* - the published contract names every error code the route emits.
|
||||
*
|
||||
* The invalidation cases are here because a missing one is invisible: the
|
||||
* write succeeds, the response is right, and the person keeps seeing the old
|
||||
* number for an hour.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { GET, PATCH } from "@/app/api/auth/me/health-score-config/route";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import type { ScorePillarId } from "@/lib/analytics/score/types";
|
||||
import { caches } from "@/lib/cache/server-cache";
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import {
|
||||
makeSession,
|
||||
makeUser,
|
||||
type SessionFixture,
|
||||
} from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/auth/me/health-score-config";
|
||||
|
||||
const ALL_PILLARS: ScorePillarId[] = [
|
||||
"BLOOD_PRESSURE",
|
||||
"GLYCAEMIA",
|
||||
"ACTIVITY",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"LIPIDS",
|
||||
];
|
||||
|
||||
interface ResolvedBody {
|
||||
pillars: ScorePillarId[];
|
||||
excludedPillars: ScorePillarId[];
|
||||
hasSelection: boolean;
|
||||
version: number;
|
||||
changedAt: string | null;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface StoredConfig {
|
||||
excludedPillars: ScorePillarId[];
|
||||
version: number;
|
||||
changedAt: string;
|
||||
}
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
function isStoredConfig(value: unknown): value is StoredConfig {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"excludedPillars" in value &&
|
||||
"version" in value &&
|
||||
"changedAt" in value
|
||||
);
|
||||
}
|
||||
|
||||
async function storedConfig(userId: string): Promise<StoredConfig | null> {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { healthScoreConfigJson: true },
|
||||
});
|
||||
const value: unknown = row.healthScoreConfigJson;
|
||||
if (value === null) return null;
|
||||
if (!isStoredConfig(value)) throw new Error("unexpected stored config");
|
||||
return value;
|
||||
}
|
||||
|
||||
async function storedUpdatedAt(userId: string): Promise<Date> {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
return row.updatedAt;
|
||||
}
|
||||
|
||||
async function userWithConfig(
|
||||
config: Prisma.InputJsonValue | undefined = undefined,
|
||||
) {
|
||||
return makeUser(
|
||||
config === undefined ? {} : { healthScoreConfigJson: config },
|
||||
);
|
||||
}
|
||||
|
||||
function patch(auth: SessionFixture, body: unknown) {
|
||||
return callRoute<ResolvedBody>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body,
|
||||
auth,
|
||||
});
|
||||
}
|
||||
|
||||
function seedScoreCache(userId: string): void {
|
||||
caches.analytics.set(`${userId}|score`, { score: 71 });
|
||||
caches.insightsDerived.set(`${userId}|derived`, { score: 71 });
|
||||
}
|
||||
|
||||
function scoreCacheEvicted(userId: string): boolean {
|
||||
return (
|
||||
caches.analytics.get(`${userId}|score`) === null &&
|
||||
caches.insightsDerived.get(`${userId}|derived`) === null
|
||||
);
|
||||
}
|
||||
|
||||
describe("GET /api/auth/me/health-score-config", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
|
||||
it("answers every pillar counting and no selection for a fresh account", async () => {
|
||||
const user = await userWithConfig();
|
||||
const res = await callRoute<ResolvedBody>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.pillars).toEqual(ALL_PILLARS);
|
||||
expect(res.data.hasSelection).toBe(false);
|
||||
expect(res.data.version).toBe(0);
|
||||
// Nothing to guard a first write against, so no token.
|
||||
expect(res.data).not.toHaveProperty("updatedAt");
|
||||
});
|
||||
|
||||
it("answers a stored recipe as the pillars it leaves counting, with the row's token", async () => {
|
||||
const user = await userWithConfig({
|
||||
excludedPillars: ["SLEEP", "LIPIDS"],
|
||||
version: 2,
|
||||
changedAt: "2026-07-30T08:00:00.000Z",
|
||||
});
|
||||
const res = await callRoute<ResolvedBody>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.data.pillars).toEqual(
|
||||
ALL_PILLARS.filter((id) => id !== "SLEEP" && id !== "LIPIDS"),
|
||||
);
|
||||
expect(res.data.excludedPillars).toEqual(["SLEEP", "LIPIDS"]);
|
||||
expect(res.data.hasSelection).toBe(true);
|
||||
expect(res.data.version).toBe(2);
|
||||
expect(res.data.updatedAt).toBe(
|
||||
(await storedUpdatedAt(user.id)).toISOString(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/health-score-config", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { pillars: ALL_PILLARS },
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(await prisma.auditLog.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("stores the complement of the selection, answers the selection and audits it", async () => {
|
||||
const user = await userWithConfig();
|
||||
const selection: ScorePillarId[] = [
|
||||
"BLOOD_PRESSURE",
|
||||
"ACTIVITY",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
];
|
||||
const res = await patch(await makeSession(user), { pillars: selection });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.pillars).toEqual(selection);
|
||||
expect(res.data.hasSelection).toBe(true);
|
||||
expect(res.data.updatedAt).toBe(
|
||||
(await storedUpdatedAt(user.id)).toISOString(),
|
||||
);
|
||||
|
||||
const stored = await storedConfig(user.id);
|
||||
expect(stored?.excludedPillars).toEqual(
|
||||
ALL_PILLARS.filter((id) => !selection.includes(id)),
|
||||
);
|
||||
expect(stored?.version).toBe(1);
|
||||
expect(stored?.changedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: user.id, action: "user.health_score_config.update" },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
|
||||
counted: selection,
|
||||
excluded: ALL_PILLARS.filter((id) => !selection.includes(id)),
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises the recipe version by one on every accepted write", async () => {
|
||||
const user = await userWithConfig({
|
||||
excludedPillars: ["LIPIDS"],
|
||||
version: 7,
|
||||
changedAt: "2026-07-01T00:00:00.000Z",
|
||||
});
|
||||
const session = await makeSession(user);
|
||||
await patch(session, { pillars: ALL_PILLARS });
|
||||
expect((await storedConfig(user.id))?.version).toBe(8);
|
||||
await patch(session, { pillars: ["SLEEP"] });
|
||||
expect((await storedConfig(user.id))?.version).toBe(9);
|
||||
});
|
||||
|
||||
it("keeps a keep-everything selection as an authored choice", async () => {
|
||||
const user = await userWithConfig();
|
||||
const res = await patch(await makeSession(user), { pillars: ALL_PILLARS });
|
||||
expect(res.data.hasSelection).toBe(true);
|
||||
expect((await storedConfig(user.id))?.excludedPillars).toEqual([]);
|
||||
});
|
||||
|
||||
it("evicts the account's score caches so the old number is not served on", async () => {
|
||||
const user = await userWithConfig();
|
||||
const other = await makeUser();
|
||||
seedScoreCache(user.id);
|
||||
seedScoreCache(other.id);
|
||||
|
||||
const res = await patch(await makeSession(user), { pillars: ALL_PILLARS });
|
||||
expect(res.status).toBe(200);
|
||||
expect(scoreCacheEvicted(user.id)).toBe(true);
|
||||
expect(scoreCacheEvicted(other.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Since v1.38 the breadth floor is a recommendation: the scorer computes
|
||||
* from whatever is readable and labels how broad the set was. The empty
|
||||
* selection stays refused, because there is nothing left to average.
|
||||
*/
|
||||
describe("PATCH: the breadth rule", () => {
|
||||
it.each<[string, ScorePillarId[]]>([
|
||||
["activity and wellbeing alone", ["ACTIVITY", "WELLBEING"]],
|
||||
[
|
||||
"the cardiometabolic triple, three pillars in one area",
|
||||
["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"],
|
||||
],
|
||||
["a single pillar", ["ACTIVITY"]],
|
||||
["a three-area selection", ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"]],
|
||||
])("accepts %s, stores it and evicts the score", async (_, pillars) => {
|
||||
const user = await userWithConfig();
|
||||
seedScoreCache(user.id);
|
||||
const res = await patch(await makeSession(user), { pillars });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.pillars).toEqual(pillars);
|
||||
expect((await storedConfig(user.id))?.excludedPillars).toEqual(
|
||||
ALL_PILLARS.filter((id) => !pillars.includes(id)),
|
||||
);
|
||||
expect(scoreCacheEvicted(user.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses an empty selection rather than storing a score that cannot exist", async () => {
|
||||
const user = await userWithConfig();
|
||||
seedScoreCache(user.id);
|
||||
const res = await patch(await makeSession(user), { pillars: [] });
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta?.errorCode).toBe("health_score_config.too_narrow");
|
||||
expect(res.meta?.reason).toBe("no_pillars_selected");
|
||||
expect(res.error).toContain("at least one area of health");
|
||||
expect(await storedConfig(user.id)).toBeNull();
|
||||
expect(scoreCacheEvicted(user.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH: envelope and transport", () => {
|
||||
it.each<[string, Record<string, unknown>]>([
|
||||
[
|
||||
"an unknown pillar id",
|
||||
{ pillars: ["BLOOD_PRESSURE", "MOON_PHASE", "SLEEP"] },
|
||||
],
|
||||
["an unknown body key", { pillars: ALL_PILLARS, weights: { SLEEP: 2 } }],
|
||||
["a userId in the body", { pillars: ALL_PILLARS, userId: "someone-else" }],
|
||||
])("refuses %s with 422 and writes nothing", async (_, body) => {
|
||||
const user = await userWithConfig();
|
||||
const res = await patch(await makeSession(user), body);
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta?.errorCode).toBe("health_score_config.invalid");
|
||||
expect(await storedConfig(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a body that is not JSON with 422", async () => {
|
||||
const user = await userWithConfig();
|
||||
// The kit serialises every body as JSON, so the unparseable body here is
|
||||
// the empty one, sent with a JSON content type.
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
headers: { "content-type": "application/json" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.error).toBe("health-score-config.body.invalid_json");
|
||||
expect(await storedConfig(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
|
||||
const user = await userWithConfig();
|
||||
const session = await makeSession(user);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const ok = await patch(session, { pillars: ALL_PILLARS });
|
||||
expect(ok.status).toBe(200);
|
||||
}
|
||||
const refused = await patch(session, { pillars: ["SLEEP"] });
|
||||
expect(refused.status).toBe(429);
|
||||
const stored = await storedConfig(user.id);
|
||||
expect(stored?.version).toBe(60);
|
||||
expect(stored?.excludedPillars).toEqual([]);
|
||||
});
|
||||
|
||||
it("answers a stale base token with 409, writes nothing and keeps the caches", async () => {
|
||||
const user = await userWithConfig({
|
||||
excludedPillars: ["LIPIDS"],
|
||||
version: 3,
|
||||
changedAt: "2026-07-01T00:00:00.000Z",
|
||||
});
|
||||
const session = await makeSession(user);
|
||||
const read = await callRoute<ResolvedBody>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: session,
|
||||
});
|
||||
const base = read.data.updatedAt;
|
||||
expect(base).toBeDefined();
|
||||
|
||||
// Another device saves first, with the same token.
|
||||
const first = await patch(session, {
|
||||
pillars: ALL_PILLARS,
|
||||
baseUpdatedAt: base,
|
||||
});
|
||||
expect(first.status).toBe(200);
|
||||
seedScoreCache(user.id);
|
||||
|
||||
const second = await patch(session, {
|
||||
pillars: ["SLEEP"],
|
||||
baseUpdatedAt: base,
|
||||
});
|
||||
expect(second.status).toBe(409);
|
||||
expect(second.meta?.errorCode).toBe("health_score_config_conflict");
|
||||
const stored = await storedConfig(user.id);
|
||||
expect(stored?.version).toBe(4);
|
||||
expect(stored?.excludedPillars).toEqual([]);
|
||||
expect(scoreCacheEvicted(user.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The route shipped once with a settings surface in front of it and no
|
||||
* published contract. The kit's contract check proves each body matches its
|
||||
* schema; this block proves the descriptions a client reads name every error
|
||||
* code the real route emits, on the status it emits it with.
|
||||
*/
|
||||
describe("the published contract names what the route serves", () => {
|
||||
type Doc = {
|
||||
paths?: Record<
|
||||
string,
|
||||
Record<string, { responses?: Record<string, { description?: string }> }>
|
||||
>;
|
||||
components?: {
|
||||
schemas?: Record<
|
||||
string,
|
||||
{ properties?: Record<string, unknown>; required?: string[] }
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
function isDoc(value: unknown): value is Doc {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function doc(): Doc {
|
||||
const built: unknown = buildOpenApiDocument();
|
||||
if (!isDoc(built)) throw new Error("no OpenAPI document");
|
||||
return built;
|
||||
}
|
||||
|
||||
it("registers both verbs", () => {
|
||||
expect(Object.keys(doc().paths?.[ROUTE] ?? {}).sort()).toEqual([
|
||||
"get",
|
||||
"patch",
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes exactly the fields a real write answers, updatedAt optional", async () => {
|
||||
// The kit refuses a key the schema does not declare; this is the other
|
||||
// direction, a published field the route never sends.
|
||||
const user = await userWithConfig();
|
||||
const res = await patch(await makeSession(user), { pillars: ALL_PILLARS });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const schema = doc().components?.schemas?.["HealthScoreConfigResponse"];
|
||||
expect(schema, "HealthScoreConfigResponse is not emitted").toBeDefined();
|
||||
expect(Object.keys(schema?.properties ?? {}).sort()).toEqual(
|
||||
Object.keys(res.data).sort(),
|
||||
);
|
||||
// Absent on a never-configured read (proven by the GET case above, which
|
||||
// the kit parses against this schema), so it must not be required.
|
||||
expect(schema?.required ?? []).not.toContain("updatedAt");
|
||||
});
|
||||
|
||||
it("names every errorCode the route can emit, on the right status", async () => {
|
||||
const emitted = new Map<number, Set<string>>();
|
||||
const record = async (
|
||||
res: Awaited<ReturnType<typeof patch>>,
|
||||
): Promise<void> => {
|
||||
const code = res.meta?.errorCode;
|
||||
if (typeof code !== "string") return;
|
||||
const set = emitted.get(res.status) ?? new Set<string>();
|
||||
set.add(code);
|
||||
emitted.set(res.status, set);
|
||||
};
|
||||
|
||||
const user = await userWithConfig();
|
||||
const session = await makeSession(user);
|
||||
// Too narrow: the empty selection.
|
||||
await record(await patch(session, { pillars: [] }));
|
||||
// A body that fails validation.
|
||||
await record(await patch(session, { pillars: ["NOT_A_PILLAR"] }));
|
||||
// A present but unparseable base token.
|
||||
await record(
|
||||
await patch(session, { pillars: ALL_PILLARS, baseUpdatedAt: null }),
|
||||
);
|
||||
// A stale base token.
|
||||
await record(
|
||||
await patch(session, {
|
||||
pillars: ALL_PILLARS,
|
||||
baseUpdatedAt: "2020-01-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
// The driver really produced the codes, so a contract naming none of
|
||||
// them could not pass by the loop never running.
|
||||
expect([...(emitted.get(422) ?? [])].sort()).toEqual([
|
||||
"health_score_config.invalid",
|
||||
"health_score_config.too_narrow",
|
||||
"invalid_base_updated_at",
|
||||
]);
|
||||
expect([...(emitted.get(409) ?? [])]).toEqual([
|
||||
"health_score_config_conflict",
|
||||
]);
|
||||
|
||||
const responses = doc().paths?.[ROUTE]?.patch?.responses ?? {};
|
||||
for (const [status, codes] of emitted) {
|
||||
const described = responses[String(status)]?.description ?? "";
|
||||
for (const code of codes) {
|
||||
expect(
|
||||
described,
|
||||
`${status} description does not mention \`${code}\``,
|
||||
).toContain(code);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* `/api/auth/me/modules` — the per-account module switches, against the real
|
||||
* database and through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - the read resolves every toggleable module on for a fresh account, a
|
||||
* stored `false` off, and the delegated modules from their real source
|
||||
* (cycle from gender and the cycle profile, coach from the operator's
|
||||
* switch);
|
||||
* - the write merges the sent keys into the stored DISABLED allowlist key
|
||||
* by key, drops junk a corrupted row carried, can turn a module back on,
|
||||
* and audits which keys changed;
|
||||
* - a core domain, an unknown key or a non-boolean value is a 422, a body
|
||||
* that is not JSON a 400 with `modules.body.invalid_json`, and none of
|
||||
* them touch the row;
|
||||
* - a write evicts the account's cached score, and a refused write does
|
||||
* not;
|
||||
* - the optimistic-concurrency token is stripped before the strict parse,
|
||||
* a stale one answers 409 `modules_conflict` and writes nothing, a
|
||||
* malformed one 422 `invalid_base_updated_at`;
|
||||
* - the write bucket allows 60 a minute per account.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { GET, PATCH } from "@/app/api/auth/me/modules/route";
|
||||
import { caches } from "@/lib/cache/server-cache";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/auth/me/modules";
|
||||
|
||||
type ModulesResponse = {
|
||||
modules: Record<string, boolean>;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
const NO_400 =
|
||||
"the registry declares no 400 for PATCH /api/auth/me/modules although the handler answers one for a body that is not JSON";
|
||||
const NO_409 =
|
||||
"the registry declares no 409 for PATCH /api/auth/me/modules although the handler answers modules_conflict for a stale base token";
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
async function storedPrefs(userId: string): Promise<unknown> {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { modulePreferencesJson: true },
|
||||
});
|
||||
return row.modulePreferencesJson;
|
||||
}
|
||||
|
||||
async function storedUpdatedAt(userId: string): Promise<Date> {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
return row.updatedAt;
|
||||
}
|
||||
|
||||
async function patch(
|
||||
auth: Awaited<ReturnType<typeof makeSession>>,
|
||||
body: Record<string, unknown>,
|
||||
contract?: { skip: string },
|
||||
) {
|
||||
return callRoute<ModulesResponse>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body,
|
||||
auth,
|
||||
contract,
|
||||
});
|
||||
}
|
||||
|
||||
describe("GET /api/auth/me/modules", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
|
||||
it("answers every toggleable module on for a fresh account, with its token", async () => {
|
||||
const user = await makeUser({ gender: null });
|
||||
const res = await callRoute<ModulesResponse>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.modules.mood).toBe(true);
|
||||
expect(res.data.modules.sleep).toBe(true);
|
||||
expect(res.data.modules.insights).toBe(true);
|
||||
// cycle derives from gender (unknown means off); coach follows the
|
||||
// operator switch, on by default.
|
||||
expect(res.data.modules.cycle).toBe(false);
|
||||
expect(res.data.modules.coach).toBe(true);
|
||||
expect(res.data.updatedAt).toBe(
|
||||
(await storedUpdatedAt(user.id)).toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("answers a module off when the stored allowlist says false", async () => {
|
||||
const user = await makeUser({ modulePreferencesJson: { glucose: false } });
|
||||
const res = await callRoute<ModulesResponse>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.data.modules.glucose).toBe(false);
|
||||
expect(res.data.modules.mood).toBe(true);
|
||||
});
|
||||
|
||||
it("answers coach off while the operator has the coach switched off", async () => {
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: { id: "singleton", assistantCoachEnabled: false },
|
||||
update: { assistantCoachEnabled: false },
|
||||
});
|
||||
const user = await makeUser({ disableCoach: false });
|
||||
const res = await callRoute<ModulesResponse>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.data.modules.coach).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/modules", () => {
|
||||
it("refuses an anonymous caller with 401 and stores nothing", async () => {
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { mood: false },
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(await prisma.auditLog.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("disables a module, stores the allowlist and audits the changed key", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await patch(await makeSession(user), { mood: false });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.modules.mood).toBe(false);
|
||||
expect(await storedPrefs(user.id)).toEqual({ mood: false });
|
||||
expect(res.data.updatedAt).toBe(
|
||||
(await storedUpdatedAt(user.id)).toISOString(),
|
||||
);
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: user.id, action: "user.modules.update" },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
|
||||
changed: ["mood"],
|
||||
});
|
||||
});
|
||||
|
||||
it("merges over the stored allowlist key by key, siblings intact", async () => {
|
||||
const user = await makeUser({ modulePreferencesJson: { glucose: false } });
|
||||
const res = await patch(await makeSession(user), { sleep: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedPrefs(user.id)).toEqual({
|
||||
glucose: false,
|
||||
sleep: false,
|
||||
});
|
||||
expect(res.data.modules.glucose).toBe(false);
|
||||
expect(res.data.modules.sleep).toBe(false);
|
||||
});
|
||||
|
||||
it("turns a disabled module back on", async () => {
|
||||
const user = await makeUser({ modulePreferencesJson: { glucose: false } });
|
||||
const res = await patch(await makeSession(user), { glucose: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedPrefs(user.id)).toEqual({ glucose: true });
|
||||
expect(res.data.modules.glucose).toBe(true);
|
||||
});
|
||||
|
||||
it("drops junk a corrupted stored row carried when it merges", async () => {
|
||||
const user = await makeUser({
|
||||
modulePreferencesJson: { mood: "yes", sleep: false },
|
||||
});
|
||||
const res = await patch(await makeSession(user), { labs: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedPrefs(user.id)).toEqual({ sleep: false, labs: false });
|
||||
});
|
||||
|
||||
it("refuses to disable a core domain with 422", async () => {
|
||||
const user = await makeUser();
|
||||
const session = await makeSession(user);
|
||||
// medications is a toggleable module; weight, blood pressure and pulse
|
||||
// are the core domains that stay refused.
|
||||
for (const core of ["weight", "bloodPressure", "pulse"]) {
|
||||
const res = await patch(session, { [core]: false });
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta?.errorCode).toBe("modules.invalid");
|
||||
}
|
||||
expect(await storedPrefs(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses an unknown key with 422", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await patch(await makeSession(user), { notAModule: false });
|
||||
expect(res.status).toBe(422);
|
||||
expect(await storedPrefs(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a non-boolean value with 422", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await patch(await makeSession(user), { mood: "false" });
|
||||
expect(res.status).toBe(422);
|
||||
expect(await storedPrefs(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a body that is not JSON with 400 and its error code", async () => {
|
||||
const user = await makeUser();
|
||||
// The kit serialises every body as JSON, so the unparseable body here is
|
||||
// the empty one, sent with a JSON content type.
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
headers: { "content-type": "application/json" },
|
||||
auth: await makeSession(user),
|
||||
contract: { skip: NO_400 },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.error).toBe("Invalid JSON body");
|
||||
expect(res.meta?.errorCode).toBe("modules.body.invalid_json");
|
||||
expect(await storedPrefs(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
|
||||
const user = await makeUser();
|
||||
const session = await makeSession(user);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const ok = await patch(session, { mood: i % 2 === 0 });
|
||||
expect(ok.status).toBe(200);
|
||||
}
|
||||
// The 60th write (i = 59) set mood off.
|
||||
expect(await storedPrefs(user.id)).toEqual({ mood: false });
|
||||
|
||||
const refused = await patch(session, { mood: true });
|
||||
expect(refused.status).toBe(429);
|
||||
expect(await storedPrefs(user.id)).toEqual({ mood: false });
|
||||
|
||||
const neighbour = await patch(await makeSession(await makeUser()), {
|
||||
mood: false,
|
||||
});
|
||||
expect(neighbour.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Four of the score's pillars come and go with a module toggle, so a toggle
|
||||
* changes what the composite is made of; the cached score has to go.
|
||||
*/
|
||||
describe("PATCH /api/auth/me/modules evicts the cached score", () => {
|
||||
it("evicts the account's score caches after a toggle lands", async () => {
|
||||
const user = await makeUser();
|
||||
const other = await makeUser();
|
||||
caches.analytics.set(`${user.id}|score`, { score: 71 });
|
||||
caches.insightsDerived.set(`${user.id}|derived`, { score: 71 });
|
||||
caches.analytics.set(`${other.id}|score`, { score: 64 });
|
||||
|
||||
const res = await patch(await makeSession(user), { sleep: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(caches.analytics.get(`${user.id}|score`)).toBeNull();
|
||||
expect(caches.insightsDerived.get(`${user.id}|derived`)).toBeNull();
|
||||
expect(caches.analytics.get(`${other.id}|score`)).toEqual({ score: 64 });
|
||||
});
|
||||
|
||||
it("leaves them when the write never landed", async () => {
|
||||
const user = await makeUser();
|
||||
caches.analytics.set(`${user.id}|score`, { score: 71 });
|
||||
|
||||
const res = await patch(
|
||||
await makeSession(user),
|
||||
{ sleep: false, baseUpdatedAt: "2020-01-01T00:00:00.000Z" },
|
||||
{ skip: NO_409 },
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
expect(caches.analytics.get(`${user.id}|score`)).toEqual({ score: 71 });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
* write stays unconditional.
|
||||
*/
|
||||
describe("PATCH /api/auth/me/modules optimistic concurrency", () => {
|
||||
it("writes when the base token matches, and answers the advanced token", async () => {
|
||||
const user = await makeUser();
|
||||
const base = await storedUpdatedAt(user.id);
|
||||
const res = await patch(await makeSession(user), {
|
||||
mood: false,
|
||||
baseUpdatedAt: base.toISOString(),
|
||||
});
|
||||
// 200, not 422: the token was removed before the strict parse.
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedPrefs(user.id)).toEqual({ mood: false });
|
||||
const after = await storedUpdatedAt(user.id);
|
||||
expect(after.getTime()).toBeGreaterThan(base.getTime());
|
||||
expect(res.data.updatedAt).toBe(after.toISOString());
|
||||
});
|
||||
|
||||
it("gives a second writer holding the old token 409 and keeps the first write", async () => {
|
||||
const user = await makeUser();
|
||||
const session = await makeSession(user);
|
||||
const base = (await storedUpdatedAt(user.id)).toISOString();
|
||||
|
||||
const first = await patch(session, { mood: false, baseUpdatedAt: base });
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
const second = await patch(
|
||||
session,
|
||||
{ sleep: false, baseUpdatedAt: base },
|
||||
{ skip: NO_409 },
|
||||
);
|
||||
expect(second.status).toBe(409);
|
||||
expect(second.meta?.errorCode).toBe("modules_conflict");
|
||||
expect(await storedPrefs(user.id)).toEqual({ mood: false });
|
||||
});
|
||||
|
||||
it("writes unconditionally when the token is omitted", async () => {
|
||||
const user = await makeUser({ modulePreferencesJson: { sleep: false } });
|
||||
const session = await makeSession(user);
|
||||
// Advance the row under the client: without a token nothing is compared.
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { modulePreferencesJson: { sleep: false, labs: false } },
|
||||
});
|
||||
const res = await patch(session, { mood: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedPrefs(user.id)).toEqual({
|
||||
sleep: false,
|
||||
labs: false,
|
||||
mood: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("still refuses an unknown key beside a valid token", async () => {
|
||||
const user = await makeUser();
|
||||
const base = (await storedUpdatedAt(user.id)).toISOString();
|
||||
const res = await patch(await makeSession(user), {
|
||||
notAModule: false,
|
||||
baseUpdatedAt: base,
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(await storedPrefs(user.id)).toBeNull();
|
||||
expect((await storedUpdatedAt(user.id)).toISOString()).toBe(base);
|
||||
});
|
||||
|
||||
it("refuses a malformed token with 422 without touching the row", async () => {
|
||||
const user = await makeUser();
|
||||
const base = (await storedUpdatedAt(user.id)).toISOString();
|
||||
const res = await patch(await makeSession(user), {
|
||||
mood: false,
|
||||
baseUpdatedAt: "not-a-date",
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta?.errorCode).toBe("invalid_base_updated_at");
|
||||
expect(await storedPrefs(user.id)).toBeNull();
|
||||
expect((await storedUpdatedAt(user.id)).toISOString()).toBe(base);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* `/api/auth/me/notification-prefs` — the per-account notification
|
||||
* preferences, against the real database and through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - the read resolves a null row, a partial row and a drifted row (an
|
||||
* unknown category) to the documented full shape, with the row's
|
||||
* concurrency token;
|
||||
* - the write deep-merges the sent categories over the stored ones, stores
|
||||
* the full resolved shape, answers it with the advanced token, and audits
|
||||
* previous, next and the categories the body named;
|
||||
* - an empty body leaves the preferences as they were and still writes;
|
||||
* - a body that is not JSON is 400 `notification-prefs.body.invalid_json`,
|
||||
* an out-of-range or mistyped value is 422, and neither touches the row;
|
||||
* - a write carrying a stale base token is 409 `notification_prefs_conflict`
|
||||
* and does not revert a flag another client set; the same write without
|
||||
* a token lands, last write wins; a malformed token is 422;
|
||||
* - the write bucket allows 60 a minute per account.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { GET, PATCH } from "@/app/api/auth/me/notification-prefs/route";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import {
|
||||
makeSession,
|
||||
makeUser,
|
||||
type SessionFixture,
|
||||
} from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/auth/me/notification-prefs";
|
||||
|
||||
const DEFAULTS = {
|
||||
medication: {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
coach: {
|
||||
nudgesEnabled: true,
|
||||
nudgeMedication: true,
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: { clientManaged: false },
|
||||
content: { detail: "generic" },
|
||||
} as const;
|
||||
|
||||
type Prefs = {
|
||||
medication: {
|
||||
clientManaged: boolean;
|
||||
deliveryDefault: string;
|
||||
lowStockRunwayDays: number | null;
|
||||
reorderLeadDays: number;
|
||||
};
|
||||
mood: { reminderHour: number };
|
||||
content: { detail: string };
|
||||
updatedAt?: string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
async function stored(userId: string): Promise<unknown> {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { notificationPrefs: true },
|
||||
});
|
||||
return row.notificationPrefs;
|
||||
}
|
||||
|
||||
async function storedUpdatedAt(userId: string): Promise<string> {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
return row.updatedAt.toISOString();
|
||||
}
|
||||
|
||||
function userWithPrefs(prefs?: Prisma.InputJsonValue) {
|
||||
return makeUser(prefs === undefined ? {} : { notificationPrefs: prefs });
|
||||
}
|
||||
|
||||
function read(auth: SessionFixture) {
|
||||
return callRoute<Prefs>(GET, { method: "GET", route: ROUTE, auth });
|
||||
}
|
||||
|
||||
function patch(auth: SessionFixture, body: unknown) {
|
||||
return callRoute<Prefs>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body,
|
||||
auth,
|
||||
});
|
||||
}
|
||||
|
||||
describe("GET /api/auth/me/notification-prefs", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
|
||||
it("answers the documented defaults for an account with no stored prefs", async () => {
|
||||
const user = await userWithPrefs();
|
||||
const res = await read(await makeSession(user));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({
|
||||
...DEFAULTS,
|
||||
updatedAt: await storedUpdatedAt(user.id),
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a stored value resolved over the defaults", async () => {
|
||||
const user = await userWithPrefs({ medication: { clientManaged: true } });
|
||||
const res = await read(await makeSession(user));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({
|
||||
...DEFAULTS,
|
||||
medication: { ...DEFAULTS.medication, clientManaged: true },
|
||||
updatedAt: await storedUpdatedAt(user.id),
|
||||
});
|
||||
});
|
||||
|
||||
it("answers the defaults when the stored shape has drifted", async () => {
|
||||
// An operator hand-edit or a future rename must not break the read.
|
||||
const user = await userWithPrefs({ unknownCategory: { foo: "bar" } });
|
||||
const res = await read(await makeSession(user));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({
|
||||
...DEFAULTS,
|
||||
updatedAt: await storedUpdatedAt(user.id),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { medication: { clientManaged: true } },
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(await prisma.auditLog.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("turns medication.clientManaged on, stores the full shape and audits the change", async () => {
|
||||
const user = await userWithPrefs();
|
||||
const res = await patch(await makeSession(user), {
|
||||
medication: { clientManaged: true },
|
||||
});
|
||||
|
||||
const next = {
|
||||
...DEFAULTS,
|
||||
medication: { ...DEFAULTS.medication, clientManaged: true },
|
||||
};
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({
|
||||
...next,
|
||||
updatedAt: await storedUpdatedAt(user.id),
|
||||
});
|
||||
expect(await stored(user.id)).toEqual(next);
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: user.id, action: "user.notification-prefs.update" },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
|
||||
previous: DEFAULTS,
|
||||
next,
|
||||
changed: ["medication"],
|
||||
});
|
||||
});
|
||||
|
||||
it("deep-merges over the stored categories without overwriting them", async () => {
|
||||
const user = await userWithPrefs({
|
||||
medication: { clientManaged: false, lowStockRunwayDays: 21 },
|
||||
mood: { reminderHour: 7 },
|
||||
});
|
||||
const res = await patch(await makeSession(user), {
|
||||
medication: { clientManaged: true },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await stored(user.id)).toEqual({
|
||||
...DEFAULTS,
|
||||
medication: {
|
||||
...DEFAULTS.medication,
|
||||
clientManaged: true,
|
||||
lowStockRunwayDays: 21,
|
||||
},
|
||||
mood: { reminderHour: 7 },
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the preferences as they were on an empty body and still writes", async () => {
|
||||
const user = await userWithPrefs({ medication: { clientManaged: true } });
|
||||
const res = await patch(await makeSession(user), {});
|
||||
const same = {
|
||||
...DEFAULTS,
|
||||
medication: { ...DEFAULTS.medication, clientManaged: true },
|
||||
};
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({
|
||||
...same,
|
||||
updatedAt: await storedUpdatedAt(user.id),
|
||||
});
|
||||
// The resolved full shape replaces the partial row.
|
||||
expect(await stored(user.id)).toEqual(same);
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: user.id, action: "user.notification-prefs.update" },
|
||||
});
|
||||
expect(JSON.parse(audit[0]?.details ?? "null")).toMatchObject({
|
||||
changed: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("stores a custom mood.reminderHour", async () => {
|
||||
const user = await userWithPrefs();
|
||||
const res = await patch(await makeSession(user), {
|
||||
mood: { reminderHour: 9 },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.mood).toEqual({ reminderHour: 9 });
|
||||
expect(await stored(user.id)).toEqual({
|
||||
...DEFAULTS,
|
||||
mood: { reminderHour: 9 },
|
||||
});
|
||||
});
|
||||
|
||||
it("stores content.detail and keeps the siblings", async () => {
|
||||
const user = await userWithPrefs({ mood: { reminderHour: 7 } });
|
||||
const res = await patch(await makeSession(user), {
|
||||
content: { detail: "detailed" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.content).toEqual({ detail: "detailed" });
|
||||
expect(res.data.mood).toEqual({ reminderHour: 7 });
|
||||
expect(await stored(user.id)).toEqual({
|
||||
...DEFAULTS,
|
||||
mood: { reminderHour: 7 },
|
||||
content: { detail: "detailed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("stores a custom lowStockRunwayDays", async () => {
|
||||
const user = await userWithPrefs();
|
||||
const res = await patch(await makeSession(user), {
|
||||
medication: { lowStockRunwayDays: 14 },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.medication.lowStockRunwayDays).toBe(14);
|
||||
expect(await stored(user.id)).toMatchObject({
|
||||
medication: { ...DEFAULTS.medication, lowStockRunwayDays: 14 },
|
||||
});
|
||||
});
|
||||
|
||||
it("stores the explicit null that turns the low-stock alert off", async () => {
|
||||
const user = await userWithPrefs({
|
||||
medication: { lowStockRunwayDays: 14 },
|
||||
});
|
||||
const session = await makeSession(user);
|
||||
const res = await patch(session, {
|
||||
medication: { lowStockRunwayDays: null },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.medication.lowStockRunwayDays).toBeNull();
|
||||
expect(await stored(user.id)).toMatchObject({
|
||||
medication: { lowStockRunwayDays: null },
|
||||
});
|
||||
// And the null survives the read, rather than falling back to 7.
|
||||
expect((await read(session)).data.medication.lowStockRunwayDays).toBeNull();
|
||||
});
|
||||
|
||||
it.each<[string, Record<string, unknown>]>([
|
||||
["a non-boolean clientManaged", { medication: { clientManaged: "yes" } }],
|
||||
["an unknown content.detail", { content: { detail: "everything" } }],
|
||||
["a mood.reminderHour of 24", { mood: { reminderHour: 24 } }],
|
||||
["a lowStockRunwayDays of 0", { medication: { lowStockRunwayDays: 0 } }],
|
||||
["a lowStockRunwayDays of 61", { medication: { lowStockRunwayDays: 61 } }],
|
||||
])("refuses %s with 422 and stores nothing", async (_, body) => {
|
||||
const user = await userWithPrefs();
|
||||
const res = await patch(await makeSession(user), body);
|
||||
expect(res.status).toBe(422);
|
||||
expect(await stored(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a body that is not JSON with 400 and its error code", async () => {
|
||||
const user = await userWithPrefs();
|
||||
// The kit serialises every body as JSON, so the unparseable body here is
|
||||
// the empty one, sent with a JSON content type.
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
headers: { "content-type": "application/json" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.error).toBe("Invalid JSON body");
|
||||
expect(res.meta?.errorCode).toBe("notification-prefs.body.invalid_json");
|
||||
expect(await stored(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
|
||||
const user = await userWithPrefs();
|
||||
const session = await makeSession(user);
|
||||
for (let hour = 0; hour < 60; hour++) {
|
||||
const ok = await patch(session, { mood: { reminderHour: hour % 24 } });
|
||||
expect(ok.status).toBe(200);
|
||||
}
|
||||
// The 60th write set 59 % 24 = 11.
|
||||
const refused = await patch(session, { mood: { reminderHour: 3 } });
|
||||
expect(refused.status).toBe(429);
|
||||
expect(await stored(user.id)).toMatchObject({ mood: { reminderHour: 11 } });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A write carrying the `baseUpdatedAt` it was based on lands only while the
|
||||
* stored row still carries that token. The case it exists for: a web write
|
||||
* of one category, based on a read taken before the native client set
|
||||
* `medication.clientManaged`, used to revert the flag (double reminders).
|
||||
*/
|
||||
describe("PATCH /api/auth/me/notification-prefs optimistic concurrency", () => {
|
||||
it("writes on a matching token and answers the advanced one", async () => {
|
||||
const user = await userWithPrefs();
|
||||
const session = await makeSession(user);
|
||||
const base = (await read(session)).data.updatedAt;
|
||||
|
||||
const res = await patch(session, {
|
||||
medication: { clientManaged: true },
|
||||
baseUpdatedAt: base,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const after = await storedUpdatedAt(user.id);
|
||||
expect(after).not.toBe(base);
|
||||
expect(res.data.updatedAt).toBe(after);
|
||||
expect(await stored(user.id)).toMatchObject({
|
||||
medication: { clientManaged: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a stale web write with 409 and keeps the flag the native client set", async () => {
|
||||
const user = await userWithPrefs();
|
||||
const web = await makeSession(user);
|
||||
const staleBase = (await read(web)).data.updatedAt;
|
||||
|
||||
const native = await patch(await makeSession(user), {
|
||||
medication: { clientManaged: true },
|
||||
});
|
||||
expect(native.status).toBe(200);
|
||||
|
||||
const res = await patch(web, {
|
||||
mood: { reminderHour: 9 },
|
||||
baseUpdatedAt: staleBase,
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.data).toBeNull();
|
||||
expect(res.meta?.errorCode).toBe("notification_prefs_conflict");
|
||||
expect(await stored(user.id)).toEqual({
|
||||
...DEFAULTS,
|
||||
medication: { ...DEFAULTS.medication, clientManaged: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("lets the same write land without a token: old clients keep last write wins", async () => {
|
||||
// Deliberate: a tokenless client keeps the pre-guard race. Its write of
|
||||
// one category merges over the row, so here it does not lose the flag.
|
||||
const user = await userWithPrefs({ medication: { clientManaged: true } });
|
||||
const res = await patch(await makeSession(user), {
|
||||
mood: { reminderHour: 9 },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await stored(user.id)).toEqual({
|
||||
...DEFAULTS,
|
||||
medication: { ...DEFAULTS.medication, clientManaged: true },
|
||||
mood: { reminderHour: 9 },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a malformed token with 422 without touching the row", async () => {
|
||||
const user = await userWithPrefs();
|
||||
const before = await storedUpdatedAt(user.id);
|
||||
const res = await patch(await makeSession(user), {
|
||||
medication: { clientManaged: true },
|
||||
baseUpdatedAt: "not-a-date",
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta?.errorCode).toBe("invalid_base_updated_at");
|
||||
expect(await stored(user.id)).toBeNull();
|
||||
expect(await storedUpdatedAt(user.id)).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* `/api/auth/me/source-priority` — the per-metric source ladders, against the
|
||||
* real database and through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - the read fills every key the analytics layer needs from the defaults
|
||||
* when nothing is stored, and carries the sources the account holds data
|
||||
* from beside the ladders;
|
||||
* - the PUT stores the partial shape as sent (missing keys keep reading as
|
||||
* defaults), audits previous and next, and refuses a malformed shape
|
||||
* without touching the row.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { GET, PUT } from "@/app/api/auth/me/source-priority/route";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeMeasurement, makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/auth/me/source-priority";
|
||||
|
||||
type SourcePriority = Record<string, unknown> & {
|
||||
weight: string[];
|
||||
inUse: string[];
|
||||
};
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
async function storedLadder(userId: string) {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { sourcePriorityJson: true },
|
||||
});
|
||||
return row.sourcePriorityJson;
|
||||
}
|
||||
|
||||
describe("GET /api/auth/me/source-priority", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("answers the defaulted shape when nothing is stored", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute<SourcePriority>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toHaveProperty("weight");
|
||||
expect(res.data).toHaveProperty("metricPriority");
|
||||
expect(res.data).toHaveProperty("deviceTypePriority");
|
||||
expect(res.data.inUse).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries the sources the account holds data from beside the ladders", async () => {
|
||||
const user = await makeUser();
|
||||
const other = await makeUser();
|
||||
await makeMeasurement(user, { source: "WITHINGS" });
|
||||
await makeMeasurement(user, { source: "MANUAL" });
|
||||
await makeMeasurement(other, { source: "APPLE_HEALTH" });
|
||||
|
||||
const res = await callRoute<SourcePriority>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect([...res.data.inUse].sort()).toEqual(["MANUAL", "WITHINGS"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/source-priority", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] },
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("stores the partial shape as sent and reads it back over the defaults", async () => {
|
||||
const user = await makeUser();
|
||||
const session = await makeSession(user);
|
||||
const body = { weight: ["MANUAL", "APPLE_HEALTH", "WITHINGS"] };
|
||||
|
||||
const res = await callRoute<SourcePriority>(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body,
|
||||
auth: session,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
// The resolved ladder leads with the chosen order; the resolver appends
|
||||
// the sources the body did not rank.
|
||||
expect(res.data.weight.slice(0, 3)).toEqual(body.weight);
|
||||
expect(await storedLadder(user.id)).toEqual(body);
|
||||
|
||||
const read = await callRoute<SourcePriority>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: session,
|
||||
});
|
||||
expect(read.data.weight).toEqual(res.data.weight);
|
||||
expect(read.data).toHaveProperty("metricPriority");
|
||||
});
|
||||
|
||||
it("refuses an invalid shape with 422 and stores nothing", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { weight: "not-an-array" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta?.errorCode).toBe("source-priority.body.invalid_shape");
|
||||
expect(await storedLadder(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("audits the previous and the new shape", async () => {
|
||||
const previousShape = { weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] };
|
||||
const user = await makeUser({ sourcePriorityJson: previousShape });
|
||||
const body = { weight: ["APPLE_HEALTH", "WITHINGS", "MANUAL"] };
|
||||
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: user.id, action: "user.source-priority.update" },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
|
||||
previous: previousShape,
|
||||
next: body,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* `PUT /api/auth/me/timezone` — the account timezone, against the real
|
||||
* database and through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - any zone the engine's `Intl` knows is stored, trimmed, and audited with
|
||||
* the previous and next zone; an unknown, empty or missing zone is 422 and
|
||||
* leaves the column alone;
|
||||
* - the resolver's per-user cache follows the write;
|
||||
* - a change re-buckets the supplement nutrient days through a recompute
|
||||
* job on the real queue, and saving the same zone again queues none.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { PgBoss } from "pg-boss";
|
||||
|
||||
import { PUT } from "@/app/api/auth/me/timezone/route";
|
||||
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE } from "@/lib/medications/ingredients/recompute-job";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/auth/me/timezone";
|
||||
|
||||
let boss: PgBoss;
|
||||
|
||||
beforeAll(async () => {
|
||||
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
|
||||
await boss.start();
|
||||
await boss.createQueue(SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE);
|
||||
setGlobalBoss(boss);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
setGlobalBoss(null);
|
||||
await boss?.stop({ graceful: false });
|
||||
});
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
async function storedTimezone(userId: string): Promise<string> {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
return row.timezone;
|
||||
}
|
||||
|
||||
/** The recompute is awaited by the route, so its job is on the queue on return. */
|
||||
async function recomputeJobs(userId: string): Promise<number> {
|
||||
const rows = await prisma.$queryRaw<Array<{ n: number }>>`
|
||||
SELECT count(*)::int AS n FROM pgboss.job
|
||||
WHERE name = ${SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE}
|
||||
AND singleton_key = ${`supplement-nutrient-recompute|${userId}`}
|
||||
`;
|
||||
return rows[0]?.n ?? 0;
|
||||
}
|
||||
|
||||
describe("PUT /api/auth/me/timezone", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { timezone: "Europe/Berlin" },
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
|
||||
it("stores a valid IANA zone, answers it and audits previous and next", async () => {
|
||||
const user = await makeUser({ timezone: "Europe/Berlin" });
|
||||
const res = await callRoute<{ timezone: string }>(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { timezone: "Pacific/Auckland" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ timezone: "Pacific/Auckland" });
|
||||
expect(await storedTimezone(user.id)).toBe("Pacific/Auckland");
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: user.id, action: "user.timezone.update" },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
|
||||
previous: "Europe/Berlin",
|
||||
next: "Pacific/Auckland",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unknown zone", { timezone: "Mars/Olympus" }],
|
||||
["an empty zone", { timezone: "" }],
|
||||
["a missing field", {}],
|
||||
])("refuses %s with 422 and leaves the column alone", async (_, body) => {
|
||||
const user = await makeUser({ timezone: "Europe/Berlin" });
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(await storedTimezone(user.id)).toBe("Europe/Berlin");
|
||||
});
|
||||
|
||||
it("trims whitespace before validating and stores the trimmed zone", async () => {
|
||||
const user = await makeUser({ timezone: "Europe/Berlin" });
|
||||
const res = await callRoute<{ timezone: string }>(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { timezone: " Asia/Tokyo " },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ timezone: "Asia/Tokyo" });
|
||||
expect(await storedTimezone(user.id)).toBe("Asia/Tokyo");
|
||||
});
|
||||
|
||||
it("drops the resolver's cached zone, so the next resolution reads the new one", async () => {
|
||||
const user = await makeUser({ timezone: "Europe/Berlin" });
|
||||
expect(await resolveUserTimezone(user.id)).toBe("Europe/Berlin");
|
||||
|
||||
await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { timezone: "America/New_York" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
|
||||
expect(await resolveUserTimezone(user.id)).toBe("America/New_York");
|
||||
});
|
||||
|
||||
// The supplement nutrient rows' day keys were minted under the old zone;
|
||||
// without the recompute nothing re-buckets them.
|
||||
it("queues the nutrient recompute when the zone actually changes", async () => {
|
||||
const user = await makeUser({ timezone: "Europe/Berlin" });
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { timezone: "America/New_York" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await recomputeJobs(user.id)).toBe(1);
|
||||
});
|
||||
|
||||
it("queues no recompute when the zone is unchanged", async () => {
|
||||
const user = await makeUser({ timezone: "Europe/Berlin" });
|
||||
const res = await callRoute(PUT, {
|
||||
method: "PUT",
|
||||
route: ROUTE,
|
||||
body: { timezone: "Europe/Berlin" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await recomputeJobs(user.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* `/api/auth/me/unit-preference` — the metric/imperial display preference,
|
||||
* against the real database and through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - the read resolves anything other than `imperial` (a null on an account
|
||||
* that predates the column, or a stray value) to `metric`;
|
||||
* - the write stores the value, audits previous and next, and refuses an
|
||||
* unknown value, malformed JSON, an oversized body and a non-JSON content
|
||||
* type without touching the row;
|
||||
* - a write that changes the unit re-warms the stored texts that quote it
|
||||
* (here: the cached daily briefing, a force-warm job on the real queue),
|
||||
* and saving the same unit again does not;
|
||||
* - the write bucket allows 60 a minute per account.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { PgBoss } from "pg-boss";
|
||||
|
||||
import { GET, PATCH } from "@/app/api/auth/me/unit-preference/route";
|
||||
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { INSIGHT_PREGENERATE_QUEUE } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/auth/me/unit-preference";
|
||||
|
||||
type UnitPreference = { unitPreference: "metric" | "imperial" };
|
||||
|
||||
let boss: PgBoss;
|
||||
|
||||
beforeAll(async () => {
|
||||
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
|
||||
await boss.start();
|
||||
await boss.createQueue(INSIGHT_PREGENERATE_QUEUE);
|
||||
setGlobalBoss(boss);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
setGlobalBoss(null);
|
||||
await boss?.stop({ graceful: false });
|
||||
});
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
async function forceWarmJobs(userId: string): Promise<number> {
|
||||
const rows = await prisma.$queryRaw<Array<{ n: number }>>`
|
||||
SELECT count(*)::int AS n FROM pgboss.job
|
||||
WHERE name = ${INSIGHT_PREGENERATE_QUEUE}
|
||||
AND singleton_key = ${`force:${userId}`}
|
||||
`;
|
||||
return rows[0]?.n ?? 0;
|
||||
}
|
||||
|
||||
/** The re-warm is fire-and-forget; wait for its job to land on the queue. */
|
||||
async function waitForForceWarm(userId: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
if ((await forceWarmJobs(userId)) > 0) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`no force-warm job for ${userId}`);
|
||||
}
|
||||
|
||||
async function storedPreference(userId: string): Promise<string | null> {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { unitPreference: true },
|
||||
});
|
||||
return row.unitPreference;
|
||||
}
|
||||
|
||||
describe("GET /api/auth/me/unit-preference", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
|
||||
it("answers metric for an account whose column is null", async () => {
|
||||
const user = await makeUser({ unitPreference: null });
|
||||
const res = await callRoute<UnitPreference>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ unitPreference: "metric" });
|
||||
});
|
||||
|
||||
it("answers metric for a stored value that is not imperial", async () => {
|
||||
const user = await makeUser({ unitPreference: "furlongs" });
|
||||
const res = await callRoute<UnitPreference>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ unitPreference: "metric" });
|
||||
});
|
||||
|
||||
it("answers imperial when it is stored", async () => {
|
||||
const user = await makeUser({ unitPreference: "imperial" });
|
||||
const res = await callRoute<UnitPreference>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.data).toEqual({ unitPreference: "imperial" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/unit-preference", () => {
|
||||
it("stores imperial, answers the next state and audits previous and next", async () => {
|
||||
const user = await makeUser({ unitPreference: null });
|
||||
const res = await callRoute<UnitPreference>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { unitPreference: "imperial" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ unitPreference: "imperial" });
|
||||
expect(await storedPreference(user.id)).toBe("imperial");
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: user.id, action: "user.unit-preference.update" },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
|
||||
previous: "metric",
|
||||
next: "imperial",
|
||||
});
|
||||
|
||||
const read = await callRoute<UnitPreference>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(read.data).toEqual({ unitPreference: "imperial" });
|
||||
});
|
||||
|
||||
it("refuses an unknown value with 422 and stores nothing", async () => {
|
||||
const user = await makeUser({ unitPreference: "metric" });
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { unitPreference: "furlongs" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(await storedPreference(user.id)).toBe("metric");
|
||||
});
|
||||
|
||||
// The three cases below pin the capped `safeJson` read: malformed JSON is
|
||||
// 400 like every sibling scalar, an oversized body is 413 even when its
|
||||
// value is valid, and a non-JSON content type is 415.
|
||||
it("refuses a body that is not JSON with 400 and stores nothing", async () => {
|
||||
const user = await makeUser({ unitPreference: "metric" });
|
||||
// The kit serialises every body as JSON, so the unparseable body here is
|
||||
// the empty one, sent with a JSON content type.
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
headers: { "content-type": "application/json" },
|
||||
auth: await makeSession(user),
|
||||
contract: {
|
||||
skip: "the registry declares no 400 for this PATCH although the description names it",
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(await storedPreference(user.id)).toBe("metric");
|
||||
});
|
||||
|
||||
it("refuses a body over the 1 KB cap with 413 even when the value is valid", async () => {
|
||||
const user = await makeUser({ unitPreference: "metric" });
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { unitPreference: "imperial", padding: "x".repeat(2048) },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(413);
|
||||
expect(await storedPreference(user.id)).toBe("metric");
|
||||
});
|
||||
|
||||
it("refuses a non-JSON content type with 415", async () => {
|
||||
const user = await makeUser({ unitPreference: "metric" });
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: "unitPreference=imperial",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
auth: await makeSession(user),
|
||||
contract: {
|
||||
skip: "the registry declares no 415 for this PATCH although the description names it",
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(415);
|
||||
expect(await storedPreference(user.id)).toBe("metric");
|
||||
});
|
||||
|
||||
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
|
||||
const user = await makeUser();
|
||||
const other = await makeUser();
|
||||
const session = await makeSession(user);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const ok = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { unitPreference: i % 2 === 0 ? "imperial" : "metric" },
|
||||
auth: session,
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
}
|
||||
const refused = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { unitPreference: "imperial" },
|
||||
auth: session,
|
||||
});
|
||||
expect(refused.status).toBe(429);
|
||||
// The 60th write set metric; the refused one did not land.
|
||||
expect(await storedPreference(user.id)).toBe("metric");
|
||||
|
||||
const neighbour = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { unitPreference: "imperial" },
|
||||
auth: await makeSession(other),
|
||||
});
|
||||
expect(neighbour.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH re-warms the texts that quote the unit", () => {
|
||||
it("queues a briefing re-warm when the unit actually changes", async () => {
|
||||
const user = await makeUser({
|
||||
unitPreference: null,
|
||||
insightsCachedAt: new Date(),
|
||||
});
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { unitPreference: "imperial" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await waitForForceWarm(user.id);
|
||||
});
|
||||
|
||||
it("queues nothing when the same unit is saved again", async () => {
|
||||
const unchanged = await makeUser({
|
||||
unitPreference: "imperial",
|
||||
insightsCachedAt: new Date(),
|
||||
});
|
||||
const changed = await makeUser({
|
||||
unitPreference: "metric",
|
||||
insightsCachedAt: new Date(),
|
||||
});
|
||||
|
||||
const same = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { unitPreference: "imperial" },
|
||||
auth: await makeSession(unchanged),
|
||||
});
|
||||
expect(same.status).toBe(200);
|
||||
|
||||
// A second account that does change its unit, sent after the first: once
|
||||
// its job is on the queue, a re-warm the first write had started would
|
||||
// have landed as well.
|
||||
await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { unitPreference: "imperial" },
|
||||
auth: await makeSession(changed),
|
||||
});
|
||||
await waitForForceWarm(changed.id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(await forceWarmJobs(unchanged.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* `/api/auth/me/workout-routes` — the opt-in for keeping workout GPS tracks,
|
||||
* against the real database and through the real API kit.
|
||||
*
|
||||
* Off by default; the PATCH is a hard-set that stores the flag, audits
|
||||
* previous and next, and refuses a malformed body or an anonymous caller
|
||||
* without touching the row.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { GET, PATCH } from "@/app/api/auth/me/workout-routes/route";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import { makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/auth/me/workout-routes";
|
||||
|
||||
type WorkoutRoutes = { storeWorkoutRoutes: boolean };
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
async function storedFlag(userId: string): Promise<boolean> {
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { storeWorkoutRoutes: true },
|
||||
});
|
||||
return row.storeWorkoutRoutes;
|
||||
}
|
||||
|
||||
describe("/api/auth/me/workout-routes", () => {
|
||||
it("reads off for an account that never chose", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute<WorkoutRoutes>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ storeWorkoutRoutes: false });
|
||||
});
|
||||
|
||||
it("stores the flag, answers it, and audits the change", async () => {
|
||||
const user = await makeUser();
|
||||
const session = await makeSession(user);
|
||||
const res = await callRoute<WorkoutRoutes>(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { storeWorkoutRoutes: true },
|
||||
auth: session,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ storeWorkoutRoutes: true });
|
||||
expect(await storedFlag(user.id)).toBe(true);
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: user.id, action: "user.storeWorkoutRoutes.update" },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
|
||||
previous: false,
|
||||
next: true,
|
||||
});
|
||||
|
||||
const read = await callRoute<WorkoutRoutes>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: session,
|
||||
});
|
||||
expect(read.data).toEqual({ storeWorkoutRoutes: true });
|
||||
});
|
||||
|
||||
it("refuses a malformed body with 422 and writes nothing", async () => {
|
||||
const user = await makeUser();
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { storeWorkoutRoutes: "yes" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(await storedFlag(user.id)).toBe(false);
|
||||
expect(await prisma.auditLog.count({ where: { userId: user.id } })).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: ROUTE,
|
||||
body: { storeWorkoutRoutes: true },
|
||||
auth: anonymous,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user