style: apply prettier across the tree

One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 22:20:31 +02:00
1 parent b3fea2ef59
commit 1f4ba5939d
1000 files changed
+12130 -8385

No files matched your search

@@ -268,7 +268,10 @@ function fileHasCall(text: string, needle: string): boolean {
* parameterised insights routes resolve it from a metric map — those gate
* dynamically and are accepted as gated without a literal-key assertion).
*/
function extractModuleKeys(text: string): { hasLiteral: boolean; keys: Set<string> } {
function extractModuleKeys(text: string): {
hasLiteral: boolean;
keys: Set<string>;
} {
const keys = new Set<string>();
let hasLiteral = false;
const re = /requireModuleEnabled\([^,]+,\s*"([a-zA-Z]+)"\s*\)/g;
@@ -120,14 +120,14 @@ describe("GET /api/admin/ai-settings", () => {
describe("PUT /api/admin/ai-settings", () => {
function upsertEcho() {
vi.mocked(prisma.appSettings.upsert).mockImplementation(
(async (args: { update: Record<string, unknown> }) => ({
adminAiKeyEncrypted: null,
adminAiModel: "gpt-4o",
adminAiBaseUrl: "https://api.openai.com/v1",
...args.update,
})) as never,
);
vi.mocked(prisma.appSettings.upsert).mockImplementation((async (args: {
update: Record<string, unknown>;
}) => ({
adminAiKeyEncrypted: null,
adminAiModel: "gpt-4o",
adminAiBaseUrl: "https://api.openai.com/v1",
...args.update,
})) as never);
}
it("propagates the admin gate (non-admin cannot write)", async () => {
@@ -81,22 +81,28 @@ beforeEach(() => {
describe("POST /api/admin/import-apple-health-export", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
});
const req = new NextRequest(
"http://localhost/api/admin/import-apple-health-export",
{
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
},
);
const res = await POST(req);
expect(res.status).toBe(401);
});
it("returns 403 when a non-admin cookie session calls the route", async () => {
vi.mocked(getSession).mockResolvedValue(USER_SESSION as never);
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
});
const req = new NextRequest(
"http://localhost/api/admin/import-apple-health-export",
{
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
},
);
const res = await POST(req);
expect(res.status).toBe(403);
});
@@ -107,11 +113,14 @@ describe("POST /api/admin/import-apple-health-export", () => {
"content-type": "multipart/form-data; boundary=---x",
"content-length": String(2 * 1024 * 1024 * 1024),
});
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
method: "POST",
headers,
body: "stub",
});
const req = new NextRequest(
"http://localhost/api/admin/import-apple-health-export",
{
method: "POST",
headers,
body: "stub",
},
);
const res = await POST(req);
expect(res.status).toBe(413);
});
@@ -119,11 +128,14 @@ describe("POST /api/admin/import-apple-health-export", () => {
it("returns 429 on rate-limit exhaustion", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false });
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
});
const req = new NextRequest(
"http://localhost/api/admin/import-apple-health-export",
{
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
},
);
const res = await POST(req);
expect(res.status).toBe(429);
});
@@ -106,10 +106,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: getClientIp(request),
details: { reason: "target_user_missing", targetUserId },
});
return apiError(
`Target user '${targetUserId}' does not exist`,
422,
);
return apiError(`Target user '${targetUserId}' does not exist`, 422);
}
// Same content-hash idempotency as the user-facing route, but scoped
+2 -7
View File
@@ -22,10 +22,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import {
buildInviteUrl,
generateInviteToken,
} from "@/lib/auth/invite-token";
import { buildInviteUrl, generateInviteToken } from "@/lib/auth/invite-token";
import { inviteCreateSchema } from "@/lib/validations/invite";
export const dynamic = "force-dynamic";
@@ -77,9 +74,7 @@ export const POST = apiHandler(async (req: Request) => {
const { expiresInDays, maxUses } = parsed.data;
const rawToken = generateInviteToken();
const expiresAt = new Date(
Date.now() + expiresInDays * 24 * 60 * 60 * 1000,
);
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
const invite = await prisma.inviteToken.create({
data: {
@@ -109,9 +109,7 @@ interface DiagnosticEnvelope {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.device.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
});
@@ -80,9 +80,10 @@ interface DiagnosticPayload {
* (prefix / suffix both null) — that shape never appears in
* production, but defensive against partial inserts during testing.
*/
function maskApnsToken(
token: string | null,
): { prefix: string | null; suffix: string | null } {
function maskApnsToken(token: string | null): {
prefix: string | null;
suffix: string | null;
} {
if (!token || token.length < 16) return { prefix: null, suffix: null };
return {
prefix: token.slice(0, 8),
@@ -75,7 +75,13 @@ export const GET = apiHandler(async (request) => {
for (const row of grouped) {
const existing =
byChannel.get(row.channel) ??
({ channel: row.channel, ok: 0, error: 0, skipped: 0, total: 0 } satisfies ChannelHealthRow);
({
channel: row.channel,
ok: 0,
error: 0,
skipped: 0,
total: 0,
} satisfies ChannelHealthRow);
const count = row._count._all;
if (row.result === "ok") existing.ok += count;
else if (row.result === "error") existing.error += count;
@@ -36,8 +36,9 @@ vi.mock("@/lib/api-handler", async () => {
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(h: T): T =>
h,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
@@ -149,9 +150,7 @@ describe("POST /api/admin/notifications/test — APNS branch (v1.4.46)", () => {
eventType: "SYSTEM_ALERT",
}),
);
expect(body.data.results).toEqual([
{ channel: "APNS", success: true },
]);
expect(body.data.results).toEqual([{ channel: "APNS", success: true }]);
expect(body.data.sent).toBe(true);
});
@@ -63,8 +63,14 @@ async function ensureTelegramChannel(userId: string): Promise<boolean> {
}
function isLocale(value: string | null | undefined): value is Locale {
return value === "de" || value === "en" || value === "fr" ||
value === "es" || value === "it" || value === "pl";
return (
value === "de" ||
value === "en" ||
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl"
);
}
export const POST = apiHandler(async () => {
@@ -48,7 +48,9 @@ describe("GET /api/admin/settings/module-availability", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest("http://localhost/api/admin/settings/module-availability"),
new NextRequest(
"http://localhost/api/admin/settings/module-availability",
),
);
expect(res.status).toBe(401);
});
@@ -56,7 +58,9 @@ describe("GET /api/admin/settings/module-availability", () => {
it("returns 403 when the caller is not an admin (cookie-only boundary)", async () => {
vi.mocked(getSession).mockResolvedValue(USER_OK as never);
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest("http://localhost/api/admin/settings/module-availability"),
new NextRequest(
"http://localhost/api/admin/settings/module-availability",
),
);
expect(res.status).toBe(403);
});
@@ -65,7 +69,9 @@ describe("GET /api/admin/settings/module-availability", () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest("http://localhost/api/admin/settings/module-availability"),
new NextRequest(
"http://localhost/api/admin/settings/module-availability",
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
@@ -82,7 +88,9 @@ describe("GET /api/admin/settings/module-availability", () => {
moduleAvailabilityJson: { mood: false },
} as never);
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest("http://localhost/api/admin/settings/module-availability"),
new NextRequest(
"http://localhost/api/admin/settings/module-availability",
),
);
const body = (await res.json()) as {
data: { availability: Record<string, boolean> };
@@ -1,7 +1,12 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { invalidateAppSettings } from "@/lib/cache/invalidate";
@@ -68,7 +73,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const alreadyConfigured = Boolean(
existing?.webPushVapidPublicKey &&
existing?.webPushVapidPrivateKeyEncrypted,
existing?.webPushVapidPrivateKeyEncrypted,
);
if (alreadyConfigured && parsed.data.force !== true) {
@@ -170,5 +170,4 @@ describe("GET /api/admin/status (integration health summary)", () => {
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
});
});
@@ -97,7 +97,10 @@ describe("PUT /api/admin/users/[id]", () => {
it("returns 404 when target user does not exist", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const res = await PUT(jsonReq({ username: "testuser" }), params("u-missing"));
const res = await PUT(
jsonReq({ username: "testuser" }),
params("u-missing"),
);
expect(res.status).toBe(404);
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
+19 -25
View File
@@ -138,18 +138,14 @@ beforeEach(() => {
// findMany + null findFirst means the slim slice's parity check
// diverges and falls back to live SQL (preserves the pre-v1.4.35
// assertions in this file).
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurementRollup.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.measurementRollup.deleteMany).mockResolvedValue(
{ count: 0 } as never,
);
vi.mocked(prisma.measurementRollup.upsert).mockResolvedValue(
{} as never,
);
vi.mocked(prisma.measurementRollup.deleteMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.measurementRollup.upsert).mockResolvedValue({} as never);
// v1.4.37 W2 — `ensureUserRollupsFresh` reads `measurement.findFirst`;
// the per-type coverage probe + the rollup-recompute aggregator ride
// `$queryRaw` / `$queryRawUnsafe`. Default to empty so the route
@@ -318,12 +314,10 @@ describe("GET /api/analytics", () => {
{ type: "WEIGHT", value: 81.8, measured_at: fiveDaysAgo },
] as never);
const req = new Request(
"http://localhost/api/analytics?slice=summaries",
const req = new Request("http://localhost/api/analytics?slice=summaries");
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
req,
);
const res = await (
GET as unknown as (req: Request) => Promise<Response>
)(req);
expect(res.status).toBe(200);
// v1.4.34 IW-B — bfcache-friendly directive rides on the slim
// slice too.
@@ -369,17 +363,17 @@ describe("GET /api/analytics", () => {
// both fields rather than 403-ing the whole core-metrics payload.
function seedGlucoseRows() {
const measuredAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
vi.mocked(prisma.measurement.findMany).mockImplementation(
(async (args: { where?: { type?: string } }) => {
if (args?.where?.type === "BLOOD_GLUCOSE") {
return [
{ value: 95, measuredAt, glucoseContext: "FASTING" },
{ value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
];
}
return [];
}) as never,
);
vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: {
where?: { type?: string };
}) => {
if (args?.where?.type === "BLOOD_GLUCOSE") {
return [
{ value: 95, measuredAt, glucoseContext: "FASTING" },
{ value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
];
}
return [];
}) as never);
}
it("serves glucose clinical + per-context when glucose enabled", async () => {
+6 -3
View File
@@ -72,7 +72,9 @@ export const GET = apiHandler(async (request?: Request) => {
// instantly while one background recompute refreshes them, instead
// of paying the 2-SQL-pass rebuild inline.
const slim = await cachedSwr(
caches.analytics as ServerCache<Awaited<ReturnType<typeof computeSummariesSlice>>>,
caches.analytics as ServerCache<
Awaited<ReturnType<typeof computeSummariesSlice>>
>,
`${user.id}|summaries`,
() => computeSummariesSlice(user.id),
annotate,
@@ -95,7 +97,9 @@ export const GET = apiHandler(async (request?: Request) => {
// hero score; after a measurement sync the next mount serves the
// prior body instantly while one background recompute refreshes it.
const cachedBody = await cachedSwr(
caches.analytics as ServerCache<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
caches.analytics as ServerCache<
Awaited<ReturnType<typeof buildAnalyticsResponse>>
>,
`${user.id}|default`,
() => buildAnalyticsResponse(user),
annotate,
@@ -596,7 +600,6 @@ async function computeSleepStageBreakdown(
// used by the weight-weekday + day-key helpers inside the old inline
// correlation builder.
// v1.4.37 W2 — `mapMeasurementSourceToLabel`, `uniqueComponentSources`,
// and the inline `computeUserHealthScore` body relocated to
// `src/lib/analytics/health-score-fast-path.ts` so the probe-gated
@@ -52,8 +52,7 @@ vi.mock("@/lib/auth/hmac", () => ({
// privacy contract is "the typed identifier never serialises into
// the row's body", so the mock must not embed the raw input.
hashToken: vi.fn(
(raw: string) =>
"aa".repeat(32) + (raw.length % 8).toString(16),
(raw: string) => "aa".repeat(32) + (raw.length % 8).toString(16),
),
}));
@@ -7,7 +7,7 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
},
},
toJson: <T,>(v: T) => v,
toJson: <T>(v: T) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -87,12 +87,14 @@ function mkPatch(body: unknown): Request {
* (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;
} = {}) {
function primeUser(
over: {
gender?: string | null;
disableCoach?: boolean;
modulePreferencesJson?: unknown;
cycleTrackingEnabled?: boolean | null;
} = {},
) {
const row: {
gender: string | null;
disableCoach: boolean;
@@ -102,9 +104,8 @@ function primeUser(over: {
disableCoach: over.disableCoach ?? false,
modulePreferencesJson: over.modulePreferencesJson ?? null,
};
vi.mocked(prisma.user.findUnique).mockImplementation(
(() => Promise.resolve({ ...row })) as never,
);
vi.mocked(prisma.user.findUnique).mockImplementation((() =>
Promise.resolve({ ...row })) as never);
vi.mocked(prisma.user.update).mockImplementation(((args: {
data?: { modulePreferencesJson?: unknown };
}) => {
@@ -213,7 +214,9 @@ describe("PATCH /api/auth/me/modules", () => {
// user previously disabled glucose; now disabling sleep too.
primeUser({ modulePreferencesJson: { glucose: false } });
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ sleep: false }));
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ sleep: false }),
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
@@ -225,7 +228,9 @@ describe("PATCH /api/auth/me/modules", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: { glucose: false } });
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ glucose: true }));
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucose: true }),
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
@@ -299,7 +304,9 @@ describe("PATCH /api/auth/me/modules", () => {
// 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 }));
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ labs: false }),
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
@@ -131,9 +131,7 @@ describe("GET /api/auth/me/research-mode", () => {
};
expect(env.data.enabled).toBe(true);
expect(env.data.acknowledgedAt).toBe(ackAt.toISOString());
expect(env.data.acknowledgedVersion).toBe(
RESEARCH_MODE_DISCLAIMER_VERSION,
);
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
});
});
@@ -165,9 +163,7 @@ describe("POST /api/auth/me/research-mode", () => {
data: { enabled: boolean; acknowledgedVersion: string };
};
expect(env.data.enabled).toBe(true);
expect(env.data.acknowledgedVersion).toBe(
RESEARCH_MODE_DISCLAIMER_VERSION,
);
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
+2 -1
View File
@@ -171,7 +171,8 @@ export const POST = apiHandler(async (req: Request) => {
previous: previous ?? null,
next: {
...next,
researchModeAcknowledgedAt: next.researchModeAcknowledgedAt.toISOString(),
researchModeAcknowledgedAt:
next.researchModeAcknowledgedAt.toISOString(),
},
},
});
@@ -131,7 +131,10 @@ describe("PATCH /api/auth/me/unit-preference", () => {
"user.unit-preference.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({ previous: "metric", next: "imperial" }),
details: expect.objectContaining({
previous: "metric",
next: "imperial",
}),
}),
);
});
@@ -1,9 +1,6 @@
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { apiSuccess } from "@/lib/api-response";
import {
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -53,10 +53,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyAuthentication } from "@/lib/auth/passkey";
import {
checkRateLimit,
checkAuthSurfaceRateLimit,
} from "@/lib/rate-limit";
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
const FAKE_USER = {
id: "user-1",
@@ -23,7 +23,9 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/auth/password", () => ({
verifyPassword: vi.fn().mockResolvedValue(true),
hashPassword: vi.fn().mockResolvedValue("hashed"),
checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
checkPasswordStrength: vi
.fn()
.mockReturnValue({ isAcceptable: true, feedback: [] }),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -81,9 +83,7 @@ beforeEach(() => {
describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Empty currentPassword + tooShort newPassword.
const res = await POST(
postReq({ currentPassword: "", newPassword: "x" }),
);
const res = await POST(postReq({ currentPassword: "", newPassword: "x" }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
@@ -102,9 +102,7 @@ describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces THREE simultaneous validation errors", async () => {
// Both empty + extra invalid type.
const res = await POST(
postReq({ currentPassword: "", newPassword: 123 }),
);
const res = await POST(postReq({ currentPassword: "", newPassword: 123 }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
@@ -23,7 +23,9 @@ vi.mock("@/lib/auth/session", () => ({
vi.mock("@/lib/auth/password", () => ({
hashPassword: vi.fn().mockResolvedValue("hashed"),
checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
checkPasswordStrength: vi
.fn()
.mockReturnValue({ isAcceptable: true, feedback: [] }),
}));
vi.mock("@/lib/auth/audit", () => ({
+4 -1
View File
@@ -144,7 +144,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
} else {
annotate({
action: { name: "auth.register.invite_rejected" },
meta: { reason: consumed.reason, registration_open: registrationEnabled },
meta: {
reason: consumed.reason,
registration_open: registrationEnabled,
},
});
if (!registrationEnabled) {
return apiError("Invalid or expired invite", 403);
@@ -89,9 +89,7 @@ describe("POST /api/bugreport — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
// Schema has only two knobs (description, screenshot). Pin contract
// on ≥ 2.
const res = await POST(
postReq({ description: 123, screenshot: 999 }),
);
const res = await POST(postReq({ description: 123, screenshot: 999 }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
+13 -12
View File
@@ -46,10 +46,7 @@ function reject() {
}
export const POST = apiHandler(
async (
request: NextRequest,
ctx: { params: Promise<{ token: string }> },
) => {
async (request: NextRequest, ctx: { params: Promise<{ token: string }> }) => {
const { token } = await ctx.params;
annotate({ action: { name: "share-link.unlock" } });
@@ -91,14 +88,18 @@ export const POST = apiHandler(
// Correct passphrase — mint the short-lived, token-scoped unlock cookie.
const cookieStore = await cookies();
cookieStore.set(unlockCookieName(gate.tokenHash), mintUnlockValue(gate.tokenHash), {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "strict",
// Scope to THIS token's view path only — the cookie unlocks nothing else.
path: `/c/${token}`,
maxAge: UNLOCK_TTL_SECONDS,
});
cookieStore.set(
unlockCookieName(gate.tokenHash),
mintUnlockValue(gate.tokenHash),
{
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "strict",
// Scope to THIS token's view path only — the cookie unlocks nothing else.
path: `/c/${token}`,
maxAge: UNLOCK_TTL_SECONDS,
},
);
annotate({ meta: { unlock: "granted" } });
return apiSuccess({ unlocked: true });
@@ -13,11 +13,7 @@
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
getPendingQuestionsForUser,
@@ -38,10 +38,7 @@ import {
DEFAULT_REMINDER_SUGGESTION_PREFS,
} from "@/lib/validations/coach-prefs";
import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion";
import {
CADENCE_CATALOG,
isCadenceId,
} from "@/lib/ai/coach/suggest-reminder";
import { CADENCE_CATALOG, isCadenceId } from "@/lib/ai/coach/suggest-reminder";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
@@ -36,7 +36,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -440,9 +440,7 @@ describe("append-only invariant", () => {
it("GET surfaces multi-issue validation errors (≥ 1)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
new NextRequest(
"http://localhost/api/consent/ai/latest?kind=junk",
),
new NextRequest("http://localhost/api/consent/ai/latest?kind=junk"),
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
@@ -467,10 +465,9 @@ describe("append-only invariant", () => {
it("DELETE surfaces multi-issue validation errors (≥ 1)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/consent/ai/latest?kind=junk",
{ method: "DELETE" },
),
new NextRequest("http://localhost/api/consent/ai/latest?kind=junk", {
method: "DELETE",
}),
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
+5 -6
View File
@@ -16,11 +16,7 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -76,7 +72,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
// Map each enum value to either the serialised row or `null` so the
// client always sees the full keyspace and never has to guess
// whether a missing key means "not granted" or "schema gap".
const result: Record<ConsentKind, ReturnType<typeof serialiseReceipt> | null> = {
const result: Record<
ConsentKind,
ReturnType<typeof serialiseReceipt> | null
> = {
ai_full: byKind.ai_full ? serialiseReceipt(byKind.ai_full) : null,
ai_insights_only: byKind.ai_insights_only
? serialiseReceipt(byKind.ai_insights_only)
@@ -53,7 +53,12 @@ import { apiError } from "@/lib/api-response";
const SESSION_OK = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "t", role: "USER" as const, gender: "FEMALE" },
user: {
id: "user-1",
username: "t",
role: "USER" as const,
gender: "FEMALE",
},
};
function jsonReq(url: string, method: string, body: unknown) {
@@ -92,7 +97,10 @@ describe("GET /api/cycle/symptoms/custom", () => {
describe("POST /api/cycle/symptoms/custom", () => {
it("creates a custom symptom, stores the label encrypted, returns it", async () => {
db.cycleSymptom.count.mockResolvedValue(2);
db.cycleSymptom.create.mockResolvedValue({ key: "custom:abc", icon: "Brain" });
db.cycleSymptom.create.mockResolvedValue({
key: "custom:abc",
icon: "Brain",
});
const res = await POST(
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
label: "Dizziness",
@@ -122,7 +130,9 @@ describe("POST /api/cycle/symptoms/custom", () => {
it("rejects over the per-user cap with 422", async () => {
db.cycleSymptom.count.mockResolvedValue(50);
const res = await POST(
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
label: "X",
}),
);
expect(res.status).toBe(422);
expect(db.cycleSymptom.create).not.toHaveBeenCalled();
@@ -134,7 +144,9 @@ describe("POST /api/cycle/symptoms/custom", () => {
response: apiError("Cycle tracking is not enabled", 403),
});
const res = await POST(
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
label: "X",
}),
);
expect(res.status).toBe(403);
expect(db.cycleSymptom.count).not.toHaveBeenCalled();
@@ -170,9 +182,13 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1", isActive: false });
db.cycleSymptom.count.mockResolvedValue(50);
const res = await PATCH(
jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", {
isActive: true,
}),
jsonReq(
"http://localhost/api/cycle/symptoms/custom/custom:abc",
"PATCH",
{
isActive: true,
},
),
params("custom:abc"),
);
expect(res.status).toBe(422);
@@ -188,9 +204,13 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
labelEncrypted: "enc:Renamed",
});
const res = await PATCH(
jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", {
label: "Renamed",
}),
jsonReq(
"http://localhost/api/cycle/symptoms/custom/custom:abc",
"PATCH",
{
label: "Renamed",
},
),
params("custom:abc"),
);
expect(res.status).toBe(200);
@@ -224,6 +244,8 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
params("custom:abc"),
);
expect(purge.status).toBe(200);
expect(db.cycleSymptom.delete).toHaveBeenCalledWith({ where: { id: "id1" } });
expect(db.cycleSymptom.delete).toHaveBeenCalledWith({
where: { id: "id1" },
});
});
});
+3 -1
View File
@@ -107,7 +107,9 @@ export const GET = apiHandler(async () => {
// Only surface timings on the cache-miss path (the hit path skips the
// whole builder and leaves `timings` empty).
if (Object.keys(timings).length > 0) {
annotate({ meta: { ...timings, snapshot_extras_present: body.extras !== null } });
annotate({
meta: { ...timings, snapshot_extras_present: body.extras !== null },
});
}
const response = apiSuccess(body);
@@ -325,7 +325,10 @@ describe("GET /api/dashboard/summary", () => {
};
};
const bp = body.data.metrics.find((m) => m.id === "bp");
expect(bp, "bp tile must be emitted for stale-but-valid history").toBeDefined();
expect(
bp,
"bp tile must be emitted for stale-but-valid history",
).toBeDefined();
expect(bp?.latestValue).toBe(132);
expect(bp?.secondaryValue).toBe(84);
expect(bp?.allTimeCount).toBe(28);
@@ -421,13 +424,55 @@ describe("GET /api/dashboard/summary", () => {
{ type: "PULSE", value: 71, measured_at: days(30) },
] as never)
.mockResolvedValueOnce([
{ type: "PULSE", bucket_start: days(36), mean: 68, count: 3, sum_value: null },
{ type: "PULSE", bucket_start: days(35), mean: 70, count: 3, sum_value: null },
{ type: "PULSE", bucket_start: days(34), mean: 69, count: 3, sum_value: null },
{ type: "PULSE", bucket_start: days(33), mean: 71, count: 3, sum_value: null },
{ type: "PULSE", bucket_start: days(32), mean: 73, count: 3, sum_value: null },
{ type: "PULSE", bucket_start: days(31), mean: 72, count: 3, sum_value: null },
{ type: "PULSE", bucket_start: days(30), mean: 71, count: 3, sum_value: null },
{
type: "PULSE",
bucket_start: days(36),
mean: 68,
count: 3,
sum_value: null,
},
{
type: "PULSE",
bucket_start: days(35),
mean: 70,
count: 3,
sum_value: null,
},
{
type: "PULSE",
bucket_start: days(34),
mean: 69,
count: 3,
sum_value: null,
},
{
type: "PULSE",
bucket_start: days(33),
mean: 71,
count: 3,
sum_value: null,
},
{
type: "PULSE",
bucket_start: days(32),
mean: 73,
count: 3,
sum_value: null,
},
{
type: "PULSE",
bucket_start: days(31),
mean: 72,
count: 3,
sum_value: null,
},
{
type: "PULSE",
bucket_start: days(30),
mean: 71,
count: 3,
sum_value: null,
},
] as never)
.mockResolvedValueOnce([] as never);
@@ -498,10 +543,26 @@ describe("GET /api/dashboard/summary", () => {
] as never);
// Raw per-stage rows for the night (the sleep findMany read).
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" },
{ value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" },
{ value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" },
{ value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" },
{
value: 480,
measuredAt: new Date("2026-06-03T23:00:00.000Z"),
sleepStage: "IN_BED",
},
{
value: 240,
measuredAt: new Date("2026-06-04T00:00:00.000Z"),
sleepStage: "CORE",
},
{
value: 90,
measuredAt: new Date("2026-06-04T02:00:00.000Z"),
sleepStage: "DEEP",
},
{
value: 80,
measuredAt: new Date("2026-06-04T04:00:00.000Z"),
sleepStage: "REM",
},
{ value: 20, measuredAt: wake, sleepStage: "AWAKE" },
] as never);
const res = await callGet(makeReq());
+24 -13
View File
@@ -334,9 +334,9 @@ export const GET = apiHandler(async () => {
// existing `invalidateUserMeasurements` + the v1.4.38-extended
// `invalidateUserMedications` hooks.
const body = await cached(
caches.analytics as ServerCache<Awaited<
ReturnType<typeof buildDashboardSummary>
>>,
caches.analytics as ServerCache<
Awaited<ReturnType<typeof buildDashboardSummary>>
>,
`${user.id}|dashboard-summary`,
() => buildDashboardSummary(user.id, userTz, buildContext(user)),
annotate,
@@ -350,9 +350,11 @@ interface SummaryBuilderContext {
locale: Locale;
}
function buildContext(
user: { displayName: string | null; username: string; locale: string | null },
): SummaryBuilderContext {
function buildContext(user: {
displayName: string | null;
username: string;
locale: string | null;
}): SummaryBuilderContext {
const greetingName = user.displayName ?? user.username;
// v1.5.x — accept every shipped locale (de / en / es / fr / it / pl)
// so the greeting + streak label resolve against the user's
@@ -395,7 +397,10 @@ async function buildDashboardSummary(
// perf-verify can attribute a regression to a specific sub-query
// without re-instrumenting the route.
const timings: Record<string, number> = {};
const time = async <T>(label: string, builder: () => Promise<T>): Promise<T> => {
const time = async <T>(
label: string,
builder: () => Promise<T>,
): Promise<T> => {
const t0 = Date.now();
const result = await builder();
timings[`dashboard.sub_${label}_ms`] = Date.now() - t0;
@@ -424,8 +429,10 @@ async function buildDashboardSummary(
measurementStreakDays,
sleepStageRows,
] = await Promise.all([
time("latestEver", () =>
prisma.$queryRaw<LatestEverRow[]>`
time(
"latestEver",
() =>
prisma.$queryRaw<LatestEverRow[]>`
SELECT DISTINCT ON (m."type")
m."type" AS type,
m."value"::double precision AS value,
@@ -436,8 +443,10 @@ async function buildDashboardSummary(
ORDER BY m."type", m."measured_at" DESC
`,
),
time("sparkline", () =>
prisma.$queryRaw<SparklineRow[]>`
time(
"sparkline",
() =>
prisma.$queryRaw<SparklineRow[]>`
SELECT type, bucket_start, mean, count, sum_value
FROM (
SELECT
@@ -497,8 +506,10 @@ async function buildDashboardSummary(
// conversion is folded server-side so the day boundary matches
// `userDayKey`'s output (it formats the timestamp in the user's
// tz before slicing YYYY-MM-DD).
time("streakDays", () =>
prisma.$queryRaw<ActivityDayRow[]>`
time(
"streakDays",
() =>
prisma.$queryRaw<ActivityDayRow[]>`
SELECT DISTINCT
to_char(
(m."measured_at" AT TIME ZONE ${userTz}),
@@ -37,8 +37,7 @@ vi.mock("@/lib/db-compat", () => ({
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn(),
@@ -188,11 +187,13 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
const annotated = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
expect(annotated, "validation-failed annotate call").toBeTruthy();
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
@@ -206,7 +207,7 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
256,
);
expect(meta.received_shape_excerpt as string).toContain("\"version\":2");
expect(meta.received_shape_excerpt as string).toContain('"version":2');
// zod_issues is the same sanitised array surfaced under details.issues.
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
@@ -230,11 +231,13 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
const res = await callPut(makeReq({ version: 99, widgets }));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
const annotated = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
expect((meta.received_shape_excerpt as string).length).toBe(256);
});
@@ -268,11 +271,13 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
const annotated = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
const excerpt = meta.received_shape_excerpt as string;
@@ -337,7 +342,8 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
// The persisted blob never carries the unknown id.
expect(prisma.user.update).toHaveBeenCalledTimes(1);
const updateArg = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } };
};
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
@@ -372,11 +378,13 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
// annotation fires regardless, before the Zod parse.
expect(res.status).toBe(422);
const dropAnnotate = vi.mocked(annotate).mock.calls.find(
(c) =>
(c[0] as { action?: { name?: string } }).action?.name ===
"dashboard.widgets.unknown-id-dropped",
);
const dropAnnotate = vi
.mocked(annotate)
.mock.calls.find(
(c) =>
(c[0] as { action?: { name?: string } }).action?.name ===
"dashboard.widgets.unknown-id-dropped",
);
expect(dropAnnotate, "unknown-id-dropped annotate call").toBeTruthy();
const meta = (dropAnnotate![0] as { meta?: Record<string, unknown> }).meta!;
expect(meta.dropped_count).toBe(200);
@@ -542,11 +550,13 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
expect(res.status).toBe(200);
// No id was dropped — the unknown-id annotation must NOT fire.
const dropAnnotate = vi.mocked(annotate).mock.calls.find(
(c) =>
(c[0] as { action?: { name?: string } }).action?.name ===
"dashboard.widgets.unknown-id-dropped",
);
const dropAnnotate = vi
.mocked(annotate)
.mock.calls.find(
(c) =>
(c[0] as { action?: { name?: string } }).action?.name ===
"dashboard.widgets.unknown-id-dropped",
);
expect(dropAnnotate).toBeUndefined();
expect(prisma.user.update).toHaveBeenCalledTimes(1);
@@ -557,7 +567,9 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
(w) => w.id,
);
expect(persistedIds.sort()).toEqual([...DASHBOARD_WIDGET_CATALOGUE_IDS].sort());
expect(persistedIds.sort()).toEqual(
[...DASHBOARD_WIDGET_CATALOGUE_IDS].sort(),
);
// The response body echoes the full persisted layout.
const body = (await res.json()) as {
data: { widgets: Array<{ id: string }> };
+4 -1
View File
@@ -271,7 +271,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible;
if (mergedChartOverlayPrefs === undefined || mergedHeroVisible === undefined) {
if (
mergedChartOverlayPrefs === undefined ||
mergedHeroVisible === undefined
) {
const existing = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
@@ -104,7 +104,9 @@ beforeEach(() => {
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0);
vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: null } as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: null,
} as never);
});
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
@@ -140,9 +142,7 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
it("/api/gamification/achievements scopes the measurement read to deletedAt: null", async () => {
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
const { GET } = await import(
"../../gamification/achievements/route"
);
const { GET } = await import("../../gamification/achievements/route");
await GET(mkReq("http://localhost/api/gamification/achievements"));
const calls = vi.mocked(prisma.measurement.findMany).mock.calls;
// The achievement aggregator reads recent measurements via
+4 -9
View File
@@ -104,9 +104,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const locale: Locale =
selection.locale ??
cookieLocale ??
parseLocaleFromAcceptLanguage(
request.headers.get("accept-language") ?? "",
);
parseLocaleFromAcceptLanguage(request.headers.get("accept-language") ?? "");
// BfArM ATC: an explicit selection flag wins; otherwise derive it from a
// German-region locale. The WHO ATC coding is unaffected either way.
@@ -207,12 +205,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
// format === "package": one zip holding the PDF + FHIR Bundle + a README.
const bundle = buildFhirDocumentBundle(
data,
{ insuranceNumber },
undefined,
{ germanAtc },
);
const bundle = buildFhirDocumentBundle(data, { insuranceNumber }, undefined, {
germanAtc,
});
const readme = t("doctorReport.packageReadme");
const zipped = zipSync(
{
+4 -3
View File
@@ -46,8 +46,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
? "raw"
: "night";
const [measurements, userTz, sourcePriorityJson, profile] =
await Promise.all([
const [measurements, userTz, sourcePriorityJson, profile] = await Promise.all(
[
prisma.measurement.findMany({
where,
orderBy: { measuredAt: "desc" },
@@ -60,7 +60,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
where: { id: user.id },
select: { glucoseUnit: true },
}),
]);
],
);
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
const csv = toCSV(
+1 -2
View File
@@ -17,8 +17,7 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/fhir/rest")>();
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
return { ...actual, loadFhirContext: vi.fn() };
});
vi.mock("@/lib/fhir/resources", () => ({
@@ -61,8 +61,9 @@ describe("/api/fitbit/credentials", () => {
(
prisma.fitbitConnection.delete as ReturnType<typeof vi.fn>
).mockResolvedValue({});
const res = (await (DELETE as unknown as () => Promise<{ data: unknown }>)())
.data as { deleted: boolean };
const res = (
await (DELETE as unknown as () => Promise<{ data: unknown }>)()
).data as { deleted: boolean };
expect(res.deleted).toBe(true);
expect(userUpdate).toHaveBeenCalledWith({
where: { id: "u1" },
@@ -102,7 +102,7 @@ describe("GET /api/gamification/achievements — achievements module gate", () =
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError("Module \"achievements\" is not enabled", 403, {
response: apiError('Module "achievements" is not enabled', 403, {
errorCode: "module.disabled",
module: "achievements",
}),
@@ -216,8 +216,7 @@ describe("GET /api/gamification/achievements — per-module badge skipping", ()
// A mood badge would unlock at >=1 entry; with mood OFF the createMany
// must carry no mood achievement row.
await badgesFor({ mood: false });
const createCalls = vi.mocked(prisma.userAchievement.createMany).mock
.calls;
const createCalls = vi.mocked(prisma.userAchievement.createMany).mock.calls;
for (const [arg] of createCalls) {
const rows = (arg as { data: Array<{ achievementId: string }> }).data;
expect(rows.some((r) => r.achievementId.includes("mood"))).toBe(false);
@@ -44,7 +44,11 @@ export const PATCH = apiHandler(
onsetAt: true,
},
});
if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
if (
!existing ||
existing.userId !== user.id ||
existing.deletedAt !== null
) {
return apiError("Episode not found", 404);
}
if (existing.lifecycle === "CHRONIC_ONGOING") {
@@ -75,10 +75,9 @@ function liveRow(overrides: Record<string, unknown> = {}) {
}
function req(): NextRequest {
return new NextRequest(
"http://localhost/api/illness/episodes/ep-1/restore",
{ method: "POST" },
);
return new NextRequest("http://localhost/api/illness/episodes/ep-1/restore", {
method: "POST",
});
}
beforeEach(() => {
+5 -1
View File
@@ -71,7 +71,11 @@ export const PATCH = apiHandler(
resolvedAt: true,
},
});
if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
if (
!existing ||
existing.userId !== user.id ||
existing.deletedAt !== null
) {
return apiError("Episode not found", 404);
}
+8 -2
View File
@@ -41,7 +41,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!gate.enabled) return gate.response;
const parsed = illnessInsightsQuerySchema.safeParse({
windowDays: new URL(request.url).searchParams.get("windowDays") ?? undefined,
windowDays:
new URL(request.url).searchParams.get("windowDays") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -79,7 +80,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
limit(async (): Promise<[string, number | null]> => {
const derived = await computeEpisodeCorrelation(
user.id,
{ id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle },
{
id: r.id,
onsetAt: r.onsetAt,
resolvedAt: r.resolvedAt,
lifecycle: r.lifecycle,
},
tz,
now,
);
@@ -63,8 +63,13 @@ function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
exportedAt: null,
startedAt: NOW,
completedAt: null,
progress: { currentPhase: "parsing", recordsRead: 100, rowsUpserted: 0,
percent: null, elapsedMs: 1500 },
progress: {
currentPhase: "parsing",
recordsRead: 100,
rowsUpserted: 0,
percent: null,
elapsedMs: 1500,
},
result: null,
...overrides,
};
@@ -78,10 +83,15 @@ describe("GET /api/import/apple-health-export/[jobId]/status", () => {
it("returns the row envelope to the owning user", async () => {
vi.mocked(getSession).mockResolvedValue(OWNER_SESSION as never);
mocks.importJobFindUnique.mockResolvedValue(makeRow());
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
const req = new NextRequest(
"http://localhost/api/import/apple-health-export/ij-1/status",
);
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { status: string }; error: null };
const body = (await res.json()) as {
data: { status: string };
error: null;
};
expect(body.error).toBeNull();
expect(body.data.status).toBe("parsing");
});
@@ -89,7 +99,9 @@ describe("GET /api/import/apple-health-export/[jobId]/status", () => {
it("returns 404 to a non-owner non-admin user", async () => {
vi.mocked(getSession).mockResolvedValue(OTHER_SESSION as never);
mocks.importJobFindUnique.mockResolvedValue(makeRow());
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
const req = new NextRequest(
"http://localhost/api/import/apple-health-export/ij-1/status",
);
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(404);
});
@@ -99,7 +111,9 @@ describe("GET /api/import/apple-health-export/[jobId]/status", () => {
mocks.importJobFindUnique.mockResolvedValue(
makeRow({ triggeredByAdminId: "admin-1" }),
);
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
const req = new NextRequest(
"http://localhost/api/import/apple-health-export/ij-1/status",
);
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(200);
});
@@ -107,14 +121,18 @@ describe("GET /api/import/apple-health-export/[jobId]/status", () => {
it("returns 404 when the job row is missing", async () => {
vi.mocked(getSession).mockResolvedValue(OWNER_SESSION as never);
mocks.importJobFindUnique.mockResolvedValue(null);
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
const req = new NextRequest(
"http://localhost/api/import/apple-health-export/ij-1/status",
);
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(404);
});
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
const req = new NextRequest(
"http://localhost/api/import/apple-health-export/ij-1/status",
);
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(401);
});
@@ -74,11 +74,14 @@ beforeEach(() => {
describe("POST /api/import/apple-health-export", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const req = new NextRequest("http://localhost/api/import/apple-health-export", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
});
const req = new NextRequest(
"http://localhost/api/import/apple-health-export",
{
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
},
);
const res = await POST(req);
expect(res.status).toBe(401);
});
@@ -89,11 +92,14 @@ describe("POST /api/import/apple-health-export", () => {
"content-type": "multipart/form-data; boundary=---x",
"content-length": String(2 * 1024 * 1024 * 1024),
});
const req = new NextRequest("http://localhost/api/import/apple-health-export", {
method: "POST",
headers,
body: "stub",
});
const req = new NextRequest(
"http://localhost/api/import/apple-health-export",
{
method: "POST",
headers,
body: "stub",
},
);
const res = await POST(req);
expect(res.status).toBe(413);
});
@@ -101,11 +107,14 @@ describe("POST /api/import/apple-health-export", () => {
it("returns 429 when the rate-limit window is exhausted", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false });
const req = new NextRequest("http://localhost/api/import/apple-health-export", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
});
const req = new NextRequest(
"http://localhost/api/import/apple-health-export",
{
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
},
);
const res = await POST(req);
expect(res.status).toBe(429);
});
@@ -174,9 +174,7 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
);
const res = await POST(
csvRequest(
[HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,"].join("\n"),
),
csvRequest([HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,"].join("\n")),
);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.inserted).toBe(0);
+11 -4
View File
@@ -125,7 +125,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
unit: m.unit,
measuredAt: m.measuredAt,
notes: m.notes ?? null,
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
glucoseContext:
(m.glucoseContext as GlucoseContext | undefined) ?? null,
},
create: {
userId,
@@ -136,7 +137,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
externalId: m.externalId,
measuredAt: m.measuredAt,
notes: m.notes ?? null,
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
glucoseContext:
(m.glucoseContext as GlucoseContext | undefined) ?? null,
},
select: { createdAt: true, updatedAt: true },
});
@@ -159,7 +161,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
source: "IMPORT",
measuredAt: m.measuredAt,
notes: m.notes ?? null,
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
glucoseContext:
(m.glucoseContext as GlucoseContext | undefined) ?? null,
},
});
inserted++;
@@ -232,7 +235,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const outcome = writeOutcome.get(r.line);
if (outcome === "duplicate") {
return { line: r.line, status: "skipped" as const, reason: "duplicate" };
return {
line: r.line,
status: "skipped" as const,
reason: "duplicate",
};
}
if (outcome === "updated") {
return { line: r.line, status: "updated" as const };
@@ -99,9 +99,7 @@ describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => {
// `medicationName=""` (min-1 violation) + `takenAt="not-iso"`
// forces two issues. idempotencyKey is omitted so we also catch
// the required-field check.
const res = await POST(
postReq({ medicationName: "", takenAt: "not-iso" }),
);
const res = await POST(postReq({ medicationName: "", takenAt: "not-iso" }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
@@ -311,9 +309,8 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
// ── v1.16.10 — inventory consumption seam ───────────────────────────
it("consumes inventory exactly once on the landed row", async () => {
const { consumeForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
const { consumeForIntake } =
await import("@/lib/medications/inventory/consumption");
wireHappyPath();
const res = await POST(
postReq({
@@ -334,9 +331,8 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
});
it("an idempotency replay returns the original row without consuming", async () => {
const { consumeForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
const { consumeForIntake } =
await import("@/lib/medications/inventory/consumption");
wireHappyPath();
// The replay probe finds the original event.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
@@ -57,14 +57,17 @@ import { apiError } from "@/lib/api-response";
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, locale: "en" },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
locale: "en",
},
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest(
"http://localhost/api/insights/blood-pressure-status",
);
return new NextRequest("http://localhost/api/insights/blood-pressure-status");
}
beforeEach(() => {
@@ -70,7 +70,10 @@ vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(),
createConversation: vi.fn(),
fetchConversationWithMessages: vi.fn(),
listConversations: vi.fn(async () => ({ conversations: [], nextCursor: null })),
listConversations: vi.fn(async () => ({
conversations: [],
nextCursor: null,
})),
}));
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
enqueueCoachMemoryRefresh: vi.fn(),
@@ -82,12 +85,16 @@ vi.mock("@/lib/ai/coach/budget", () => ({
recordSpend: vi.fn(),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn() }));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(),
}));
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() }));
vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel: vi.fn() }));
vi.mock("@/lib/ai/coach/keyvalues", () => ({
parseKeyValuesSentinel: vi.fn(),
}));
vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() }));
vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: vi.fn() }));
+3 -4
View File
@@ -571,7 +571,8 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
prisma,
userId,
cadence: suggestParse.cadence,
prefs: coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS,
prefs:
coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS,
});
if (decision.surface) {
const cadence = suggestParse.cadence;
@@ -613,9 +614,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
}
const enrichedProvenance: typeof snapshot.provenance = {
...snapshot.provenance,
...(sentinel.keyValues.length > 0
? { keyValues: sentinel.keyValues }
: {}),
...(sentinel.keyValues.length > 0 ? { keyValues: sentinel.keyValues } : {}),
...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}),
};
if (sentinel.malformed) {
@@ -58,8 +58,7 @@ vi.mock("@/lib/db-compat", () => ({
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn(),
@@ -167,10 +166,7 @@ describe("GET /api/insights/coach/facts", () => {
await callGet();
const orderBy = vi.mocked(prisma.coachFact.findMany).mock.calls[0]?.[0]
?.orderBy;
expect(orderBy).toEqual([
{ confidence: "desc" },
{ createdAt: "desc" },
]);
expect(orderBy).toEqual([{ confidence: "desc" }, { createdAt: "desc" }]);
});
it("skips an undecryptable row rather than 500ing the whole list", async () => {
@@ -35,12 +35,14 @@ vi.mock("@/lib/db", () => ({
// real recompute against the mocked Prisma. The route fires the
// warm-up as fire-and-forget; the return value is irrelevant.
vi.mock("@/lib/rollups/mood-rollups", async () => {
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
"@/lib/rollups/mood-rollups",
);
const actual = await vi.importActual<
typeof import("@/lib/rollups/mood-rollups")
>("@/lib/rollups/mood-rollups");
return {
...actual,
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
ensureUserMoodRollupsFresh: vi
.fn()
.mockResolvedValue({ recomputed: false }),
};
});
@@ -118,9 +120,9 @@ beforeEach(() => {
resetAt: Date.now() + 60_000,
});
// Default to assistant-on so the gate doesn't 403 every test.
(
prisma.appSettings.findUnique as ReturnType<typeof vi.fn>
).mockResolvedValue(null);
(prisma.appSettings.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue(
null,
);
(prisma.user.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
heightCm: 180,
dateOfBirth: new Date("1985-01-01"),
@@ -146,15 +148,15 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
it("emits every legacy envelope key for an empty user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue({
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: null,
totalMeasurements: 0,
});
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: null,
totalMeasurements: 0,
},
);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
@@ -202,31 +204,31 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
it("computes BMI from aggregate WEIGHT.latest and user heightCm", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue({
summaries: {
WEIGHT: {
count: 1,
latest: 81.0,
min: 81.0,
max: 81.0,
mean: 81.0,
avg7: 81.0,
avg30: 81.0,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {
WEIGHT: {
count: 1,
latest: 81.0,
min: 81.0,
max: 81.0,
mean: 81.0,
avg7: 81.0,
avg30: 81.0,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
},
},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 1,
},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 1,
});
);
const res = await callGet(makeReq());
const body = (await res.json()) as {
@@ -240,49 +242,49 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
it("pairs sys + dia raw rows with 5-min tolerance for bpPctInTarget", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const at = new Date("2026-05-10T08:00:00Z");
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue({
summaries: {
BLOOD_PRESSURE_SYS: {
count: 1,
latest: 125,
min: 125,
max: 125,
mean: 125,
avg7: 125,
avg30: 125,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {
BLOOD_PRESSURE_SYS: {
count: 1,
latest: 125,
min: 125,
max: 125,
mean: 125,
avg7: 125,
avg30: 125,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
},
BLOOD_PRESSURE_DIA: {
count: 1,
latest: 75,
min: 75,
max: 75,
mean: 75,
avg7: 75,
avg30: 75,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
},
},
BLOOD_PRESSURE_DIA: {
count: 1,
latest: 75,
min: 75,
max: 75,
mean: 75,
avg7: 75,
avg30: 75,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
bpRawRows: {
sys: [{ measuredAt: at, value: 125 }],
dia: [{ measuredAt: at, value: 75 }],
},
dailyByType: {},
firstMeasurementAt: at,
totalMeasurements: 2,
},
bpRawRows: {
sys: [{ measuredAt: at, value: 125 }],
dia: [{ measuredAt: at, value: 75 }],
},
dailyByType: {},
firstMeasurementAt: at,
totalMeasurements: 2,
});
);
const res = await callGet(makeReq());
const body = (await res.json()) as {
@@ -305,15 +307,15 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
// mean DataPoints, matching the rollup-tier convention shipped by
// `/api/mood/analytics` post-v1.4.39.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue({
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 0,
});
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 0,
},
);
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([
@@ -364,15 +366,15 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
// supposed to fire fire-and-forget so the next request lands on
// the rollup tier.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue({
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 0,
});
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 0,
},
);
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
@@ -411,18 +413,21 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
"2026-05-04",
"2026-05-05",
];
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue({
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {
WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
BLOOD_PRESSURE_SYS: days.map((d, i) => ({ day: d, value: 120 + i * 2 })),
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {
WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
BLOOD_PRESSURE_SYS: days.map((d, i) => ({
day: d,
value: 120 + i * 2,
})),
},
firstMeasurementAt: new Date(),
totalMeasurements: 10,
},
firstMeasurementAt: new Date(),
totalMeasurements: 10,
});
);
const res = await callGet(makeReq());
const body = (await res.json()) as {
@@ -454,9 +459,9 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", ()
it("marks a cold (inline-computed) read revalidating: false", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue(EMPTY_AGGREGATE);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
EMPTY_AGGREGATE,
);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
@@ -466,9 +471,9 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", ()
it("marks a stale-served read revalidating: true and a converged read false again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue(EMPTY_AGGREGATE);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
EMPTY_AGGREGATE,
);
// Prime the cache entry (cold compute).
await callGet(makeReq());
+4 -7
View File
@@ -74,9 +74,9 @@ export const GET = apiHandler(async () => {
// the response, never inside the cached body, so a warmed entry
// doesn't freeze the flag.
const { value: body, outcome } = await cachedSwrWithMeta(
caches.analytics as ServerCache<Awaited<
ReturnType<typeof buildComprehensiveResponse>
>>,
caches.analytics as ServerCache<
Awaited<ReturnType<typeof buildComprehensiveResponse>>
>,
`${user.id}|comprehensive`,
() => buildComprehensiveResponse(user),
annotate,
@@ -241,10 +241,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// small semantic shift to bound the raw-row footprint).
const weightDaily = aggregate.dailyByType.WEIGHT ?? [];
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
const weightBpPairs: PairedPoint[] = joinDailyByDay(
weightDaily,
sysDaily,
);
const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily);
const weightBpCorrelation = pearsonCorrelation(weightBpPairs);
const scatterData = weightBpPairs.map((p) => ({
weight: p.a,
@@ -5,7 +5,9 @@ vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { create: vi.fn() },
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
user: { findUnique: vi.fn().mockResolvedValue({ timezone: "Europe/Berlin" }) },
user: {
findUnique: vi.fn().mockResolvedValue({ timezone: "Europe/Berlin" }),
},
measurement: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
},
+10 -3
View File
@@ -117,7 +117,10 @@ export const GET = apiHandler(async () => {
}),
]);
const measurementsByType = new Map<string, Array<{ value: number; at: Date }>>();
const measurementsByType = new Map<
string,
Array<{ value: number; at: Date }>
>();
for (const m of measurements) {
const list = measurementsByType.get(m.type) ?? [];
list.push({ value: m.value, at: m.measuredAt });
@@ -134,12 +137,16 @@ export const GET = apiHandler(async () => {
const series: NamedSeries[] = [];
for (const key of DISCOVERY_BEHAVIOURS) {
const points =
key === "MOOD" ? moodDaily : toDailyMeans(measurementsByType.get(key) ?? [], tz);
key === "MOOD"
? moodDaily
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
series.push({ key, role: "behaviour", points });
}
for (const key of DISCOVERY_OUTCOMES) {
const points =
key === "MOOD" ? moodDaily : toDailyMeans(measurementsByType.get(key) ?? [], tz);
key === "MOOD"
? moodDaily
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
series.push({ key, role: "outcome", points });
}
@@ -59,10 +59,28 @@ vi.mock("@/lib/insights/derived", async (importOriginal) => {
...actual,
computeDerivedMetric: vi.fn(async () => ({
status: "ok",
value: { type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 },
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
value: {
type: "RESTING_HEART_RATE",
center: 55,
low: 48,
high: 62,
spread: 7,
sampleDays: 30,
k: 3,
},
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: 30,
missing: [],
},
confidence: { score: 100, band: "high" },
provenance: { inputs: ["RESTING_HEART_RATE"], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
provenance: {
inputs: ["RESTING_HEART_RATE"],
source: "DAY",
windowDays: 30,
computedAt: "2026-06-02T07:00:00+02:00",
},
})),
};
});
@@ -74,7 +92,12 @@ import { computeDerivedMetric } from "@/lib/insights/derived";
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, locale: "en" },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
locale: "en",
},
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -152,14 +175,29 @@ describe("GET /api/insights/derived", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(computeDerivedMetric).mockResolvedValueOnce({
status: "insufficient",
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: ["RESTING_HEART_RATE"] },
provenance: { inputs: ["RESTING_HEART_RATE"], source: "none", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
coverage: {
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: ["RESTING_HEART_RATE"],
},
provenance: {
inputs: ["RESTING_HEART_RATE"],
source: "none",
windowDays: 30,
computedAt: "2026-06-02T07:00:00+02:00",
},
reason: "no_readings_in_window",
} as never);
const res = await callGet(makeReq("VITALS_BASELINE"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { status: string; value: unknown; confidence: unknown; reason: string } | null;
data: {
status: string;
value: unknown;
confidence: unknown;
reason: string;
} | null;
};
expect(body.data?.status).toBe("insufficient");
expect(body.data?.value).toBeNull();
+5 -1
View File
@@ -103,7 +103,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
const moods = await prisma.moodEntry.findMany({
// v1.7.0 sync — exclude tombstoned rows.
where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: ninetyDaysAgo } },
where: {
userId: user.id,
deletedAt: null,
moodLoggedAt: { gte: ninetyDaysAgo },
},
select: { moodLoggedAt: true, tags: true },
orderBy: { moodLoggedAt: "desc" },
});
@@ -36,8 +36,7 @@ vi.mock("@/lib/db-compat", () => ({
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn(),
@@ -240,7 +239,8 @@ describe("PUT /api/insights/layout — happy path", () => {
expect(returnedIds).not.toContain("medikamente");
// The persisted blob is canonical too.
const updateCall = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
const updateCall = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { insightsLayoutJson: { tiles: Array<{ id: string }> } };
};
const persistedIds = updateCall.data.insightsLayoutJson.tiles.map(
@@ -291,9 +291,9 @@ describe("PUT /api/insights/layout — accepts version 1 (iOS contract)", () =>
data: { insightsLayoutJson: { version: number; sections: unknown[] } };
};
expect(updateCall.data.insightsLayoutJson.version).toBe(2);
expect(
Array.isArray(updateCall.data.insightsLayoutJson.sections),
).toBe(true);
expect(Array.isArray(updateCall.data.insightsLayoutJson.sections)).toBe(
true,
);
});
});
@@ -396,9 +396,9 @@ describe("PUT /api/insights/layout — full metric-slug universe", () => {
const body = (await res.json()) as {
details: { issues: Array<{ path: string }> };
};
expect(
body.details.issues.some((i) => i.path.startsWith("tiles")),
).toBe(true);
expect(body.details.issues.some((i) => i.path.startsWith("tiles"))).toBe(
true,
);
});
});
@@ -436,9 +436,9 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
const body = (await res.json()) as {
details: { issues: Array<{ path: string }> };
};
expect(
body.details.issues.some((i) => i.path.startsWith("tiles")),
).toBe(true);
expect(body.details.issues.some((i) => i.path.startsWith("tiles"))).toBe(
true,
);
});
it("writes one audit-ledger row keyed insights.layout.validation-failed", async () => {
@@ -488,11 +488,13 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"insights.layout.validation-failed",
);
const annotated = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"insights.layout.validation-failed",
);
expect(annotated, "validation-failed annotate call").toBeTruthy();
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
@@ -524,11 +526,13 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"insights.layout.validation-failed",
);
const annotated = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"insights.layout.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
const excerpt = meta.received_shape_excerpt as string;
@@ -578,7 +582,9 @@ describe("PUT /api/insights/layout — v2 sections", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { sections: Array<{ id: string; visible: boolean; order: number }> };
data: {
sections: Array<{ id: string; visible: boolean; order: number }>;
};
};
// Saved sections surface in saved order, dense 0-based.
expect(body.data.sections[0]).toEqual({
@@ -651,9 +657,9 @@ describe("PUT /api/insights/layout — v2 sections", () => {
const body = (await res.json()) as {
details: { issues: Array<{ path: string }> };
};
expect(
body.details.issues.some((i) => i.path.startsWith("sections")),
).toBe(true);
expect(body.details.issues.some((i) => i.path.startsWith("sections"))).toBe(
true,
);
});
});
@@ -58,7 +58,12 @@ import { generateMetricStatus } from "@/lib/insights/metric-status";
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, locale: "en" },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
locale: "en",
},
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
+1 -4
View File
@@ -19,10 +19,7 @@
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import {
apiSuccess,
returnAllZodIssues,
} from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -58,9 +58,7 @@ const SESSION_OK = {
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest(
new URL("http://localhost/api/insights/mood-status"),
);
return new NextRequest(new URL("http://localhost/api/insights/mood-status"));
}
/** Persisted module-preferences blob for the gated user row. */
@@ -81,9 +79,7 @@ beforeEach(() => {
describe("GET /api/insights/mood-status — module gate", () => {
it("200s when the mood module is enabled (default-on, no blob)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow(null) as never,
);
vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow(null) as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { text: string } | null };
+4 -1
View File
@@ -20,7 +20,10 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate";
import { PERIOD_DAYS, type NarrativePeriod } from "@/lib/insights/narrative/period-narrative";
import {
PERIOD_DAYS,
type NarrativePeriod,
} from "@/lib/insights/narrative/period-narrative";
import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared";
export const dynamic = "force-dynamic";
@@ -57,15 +57,19 @@ import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
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, locale: "en" },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
locale: "en",
},
};
const callPost = POST as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest(
new URL("http://localhost/api/insights/pregenerate"),
{ method: "POST" },
);
return new NextRequest(new URL("http://localhost/api/insights/pregenerate"), {
method: "POST",
});
}
beforeEach(() => {
@@ -51,12 +51,19 @@ import { prisma } from "@/lib/db";
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, locale: "en" },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
locale: "en",
},
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest(new URL("http://localhost/api/insights/rhythm-events"));
return new NextRequest(
new URL("http://localhost/api/insights/rhythm-events"),
);
}
beforeEach(() => {
+3 -1
View File
@@ -39,7 +39,9 @@ export const dynamic = "force-dynamic";
// these a year; the cap is a defensive bound, not an expected ceiling.
const MAX_EVENTS = 200;
const EVENT_TYPE_LIST = Array.from(EVENT_MEASUREMENT_TYPES) as MeasurementType[];
const EVENT_TYPE_LIST = Array.from(
EVENT_MEASUREMENT_TYPES,
) as MeasurementType[];
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -69,12 +69,14 @@ vi.mock("next/headers", () => ({
// `recomputeUserMoodRollups` aggregate. The warm-up is fire-and-forget
// on the route so its return value is irrelevant for parity.
vi.mock("@/lib/rollups/mood-rollups", async () => {
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
"@/lib/rollups/mood-rollups",
);
const actual = await vi.importActual<
typeof import("@/lib/rollups/mood-rollups")
>("@/lib/rollups/mood-rollups");
return {
...actual,
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
ensureUserMoodRollupsFresh: vi
.fn()
.mockResolvedValue({ recomputed: false }),
};
});
@@ -160,11 +162,61 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(0), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(1), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(2), count: 1, mean: 5, minScore: 5, maxScore: 5, sd: null, computedAt: new Date() },
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(3), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(4), count: 1, mean: 4.5, minScore: 4, maxScore: 5, sd: 0.5, computedAt: new Date() },
{
userId: SESSION_USER.id,
granularity: "DAY",
bucketStart: day(0),
count: 1,
mean: 4,
minScore: 4,
maxScore: 4,
sd: null,
computedAt: new Date(),
},
{
userId: SESSION_USER.id,
granularity: "DAY",
bucketStart: day(1),
count: 1,
mean: 4,
minScore: 4,
maxScore: 4,
sd: null,
computedAt: new Date(),
},
{
userId: SESSION_USER.id,
granularity: "DAY",
bucketStart: day(2),
count: 1,
mean: 5,
minScore: 5,
maxScore: 5,
sd: null,
computedAt: new Date(),
},
{
userId: SESSION_USER.id,
granularity: "DAY",
bucketStart: day(3),
count: 1,
mean: 4,
minScore: 4,
maxScore: 4,
sd: null,
computedAt: new Date(),
},
{
userId: SESSION_USER.id,
granularity: "DAY",
bucketStart: day(4),
count: 1,
mean: 4.5,
minScore: 4,
maxScore: 5,
sd: 0.5,
computedAt: new Date(),
},
]);
// Latest score read — one bounded row.
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
@@ -7,7 +7,7 @@ vi.mock("@/lib/db", () => ({
// v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths.
auditLog: { create: vi.fn() },
},
toJson: <T,>(v: T) => v,
toJson: <T>(v: T) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -85,9 +85,7 @@ describe("/api/integrations/status — Polar/Oura fold (04-M2)", () => {
const entries = await fetchEntries();
const keys = entries.map((e) => e.integration);
expect(keys).toEqual(
expect.arrayContaining(["polar", "oura"]),
);
expect(keys).toEqual(expect.arrayContaining(["polar", "oura"]));
const polar = entries.find((e) => e.integration === "polar")!;
expect(polar.connected).toBe(true);
@@ -34,11 +34,7 @@ export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "integrations.withings.resume" } });
const rl = await checkRateLimit(
`withings-resume:${user.id}`,
5,
60_000,
);
const rl = await checkRateLimit(`withings-resume:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many resume requests", 429, {
errorCode: "rate_limited_self",
@@ -117,9 +117,7 @@ describe("POST /api/internal/web-vitals", () => {
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
postRequest({ name: "CLS", value: 0.1, id: "x" }),
);
const res = await POST(postRequest({ name: "CLS", value: 0.1, id: "x" }));
expect(res.status).toBe(429);
});
@@ -61,9 +61,12 @@ const ROW = {
};
function makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/measurement-reminders/r1/complete", {
method: "POST",
});
return new NextRequest(
"http://localhost/api/measurement-reminders/r1/complete",
{
method: "POST",
},
);
}
const params = { params: Promise.resolve({ id: "r1" }) };
@@ -102,7 +105,10 @@ describe("POST /api/measurement-reminders/[id]/complete", () => {
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
});
// Forward-only guard inside the primitive returns satisfied=false.
satisfyReminderMock.mockResolvedValue({ satisfied: false, nextDueAt: null });
satisfyReminderMock.mockResolvedValue({
satisfied: false,
nextDueAt: null,
});
findUniqueOrThrowMock.mockResolvedValue({
...ROW,
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
+5 -3
View File
@@ -42,7 +42,10 @@ export const GET = apiHandler(async () => {
where: { userId: user.id, deletedAt: null },
// Most-urgent first; a null next-due (uncomputable / disabled) sinks
// to the end so the actionable items float to the top.
orderBy: [{ nextDueAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }],
orderBy: [
{ nextDueAt: { sort: "asc", nulls: "last" } },
{ createdAt: "asc" },
],
});
annotate({
@@ -89,8 +92,7 @@ async function postReminder(request: NextRequest): Promise<Response> {
const timezone = await resolveTimezone(user.id);
const now = new Date();
const anchorDate =
data.anchorDate != null ? new Date(data.anchorDate) : null;
const anchorDate = data.anchorDate != null ? new Date(data.anchorDate) : null;
// Server-authoritative next-due. No satisfy yet → anchors on
// anchorDate ?? createdAt (createdAt ≈ now for a fresh row).
@@ -107,10 +107,7 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
});
it("writes a measurements.update.validation-failed audit row", async () => {
const res = await PUT(
putReq({ value: -1, measuredAt: "junk" }),
ROUTE_CTX,
);
const res = await PUT(putReq({ value: -1, measuredAt: "junk" }), ROUTE_CTX);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
@@ -125,10 +122,7 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await PUT(
putReq({ value: -1, measuredAt: "junk" }),
ROUTE_CTX,
);
const res = await PUT(putReq({ value: -1, measuredAt: "junk" }), ROUTE_CTX);
expect(res.status).toBe(422);
});
});
@@ -217,9 +217,7 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
},
] as never);
const res = await GET(
getRequest("type=ACTIVITY_STEPS&dayKey=2026-05-15"),
);
const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2026-05-15"));
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
@@ -255,9 +253,7 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
// hour of `today` on fall-back.
it("returns a 23-hour window on the Berlin spring-forward day (2025-03-30)", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
const res = await GET(
getRequest("type=ACTIVITY_STEPS&dayKey=2025-03-30"),
);
const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2025-03-30"));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0];
const measuredAt = (call as { where: { measuredAt?: unknown } }).where
@@ -271,9 +267,7 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
it("returns a 25-hour window on the Berlin fall-back day (2025-10-26)", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
const res = await GET(
getRequest("type=ACTIVITY_STEPS&dayKey=2025-10-26"),
);
const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2025-10-26"));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0];
const measuredAt = (call as { where: { measuredAt?: unknown } }).where
@@ -335,17 +329,13 @@ describe("GET /api/measurements — schema rejections (W10 reconcile)", () => {
// 2, so the drill-down would point at the wrong day. The validator
// refine must reject it at parse time.
it("rejects impossible calendar dates on dayKey (2026-02-30)", async () => {
const res = await GET(
getRequest("type=ACTIVITY_STEPS&dayKey=2026-02-30"),
);
const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2026-02-30"));
expect(res.status).toBe(422);
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
it("rejects impossible month on dayKey (2026-13-01)", async () => {
const res = await GET(
getRequest("type=ACTIVITY_STEPS&dayKey=2026-13-01"),
);
const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2026-13-01"));
expect(res.status).toBe(422);
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
@@ -72,7 +72,9 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
{
code: "P2002",
clientVersion: "x",
meta: { target: ["userId", "type", "measuredAt", "source", "sleepStage"] },
meta: {
target: ["userId", "type", "measuredAt", "source", "sleepStage"],
},
},
);
vi.mocked(prisma.measurement.update).mockRejectedValue(p2002);
@@ -83,7 +85,10 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
);
expect(res.status).toBe(409);
const json = (await res.json()) as { error: string; meta?: { errorCode?: string } };
const json = (await res.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(json.error).toMatch(/already exists|duplicate/i);
expect(json.meta?.errorCode).toBe("measurement.duplicate_timestamp");
});
@@ -108,10 +113,9 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
);
vi.mocked(prisma.measurement.update).mockRejectedValue(otherError);
const res = await PUT(
putRequest("m-3", { value: 76 }),
{ params: Promise.resolve({ id: "m-3" }) },
);
const res = await PUT(putRequest("m-3", { value: 76 }), {
params: Promise.resolve({ id: "m-3" }),
});
// apiHandler converts uncaught errors to 500.
expect(res.status).toBe(500);
@@ -143,7 +147,9 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
{
code: "P2002",
clientVersion: "x",
meta: { target: ["userId", "type", "measuredAt", "source", "sleepStage"] },
meta: {
target: ["userId", "type", "measuredAt", "source", "sleepStage"],
},
},
);
vi.mocked(prisma.measurement.update).mockRejectedValue(p2002);
@@ -186,10 +192,9 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
unit: "kg",
} as never);
const res = await PUT(
putRequest("m-4", { value: 76 }),
{ params: Promise.resolve({ id: "m-4" }) },
);
const res = await PUT(putRequest("m-4", { value: 76 }), {
params: Promise.resolve({ id: "m-4" }),
});
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { id: string; value: number } };
@@ -324,9 +324,7 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
it("falls back to live date_trunc when source=rollup returns zero buckets", async () => {
// Empty rollup ⇒ heavy aggregate runs so brand-new accounts still
// see a correct chart on their first render.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
const liveBuckets = Array.from({ length: 7 }, (_, i) => ({
type: "WEIGHT",
bucket_start: new Date(Date.UTC(2026, 4, 8 + i, 0, 0, 0)),
@@ -106,7 +106,9 @@ describe("GET /api/measurements — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces THREE simultaneous validation errors", async () => {
// bad sortBy + bad sortDir + bad aggregate.
const res = await GET(getReq("sortBy=junk&sortDir=upside&aggregate=hourly"));
const res = await GET(
getReq("sortBy=junk&sortDir=upside&aggregate=hourly"),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
@@ -138,9 +140,7 @@ describe("GET /api/measurements — 422 multi-issue (v1.4.43 W6)", () => {
describe("POST /api/measurements — single — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Missing `type` (required enum) + missing `value` (required number).
const res = await POST(
postReq({ measuredAt: "not-an-iso-string" }),
);
const res = await POST(postReq({ measuredAt: "not-an-iso-string" }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
@@ -235,7 +235,9 @@ describe("POST /api/measurements — batch — 422 multi-issue (v1.4.43 W6)", ()
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.create.batch.validation-failed");
expect(call.data.action).toBe(
"measurements.create.batch.validation-failed",
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
+6 -2
View File
@@ -162,7 +162,10 @@ type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
// the strict insert-only contract.
const STATS_EXTERNAL_ID_PREFIX = "stats:";
function isStatsExternalId(externalId: string | null | undefined): boolean {
return typeof externalId === "string" && externalId.startsWith(STATS_EXTERNAL_ID_PREFIX);
return (
typeof externalId === "string" &&
externalId.startsWith(STATS_EXTERNAL_ID_PREFIX)
);
}
interface EntryResult {
index: number;
@@ -462,7 +465,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
// v1.8.6 W6 — scope the overwrite to the row's own source
// so a `stats:*` re-post only touches the matching
// (type, source, externalId) row.
source: p.row.source as Prisma.MeasurementCreateManyInput["source"],
source: p.row
.source as Prisma.MeasurementCreateManyInput["source"],
type: p.row.type as MeasurementType,
externalId: p.row.externalId as string,
},
@@ -20,8 +20,7 @@ vi.mock("@/lib/db-compat", () => ({
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
@@ -32,8 +32,7 @@ vi.mock("@/lib/db-compat", () => ({
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn(),
@@ -141,11 +140,36 @@ describe("GET /api/measurements/series", () => {
// total in hours, not a point per stage.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "s1", value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" },
{ id: "s2", value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" },
{ id: "s3", value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" },
{ id: "s4", value: 60, measuredAt: new Date("2026-06-04T05:00:00.000Z"), sleepStage: "AWAKE" },
{ id: "s5", value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" },
{
id: "s1",
value: 240,
measuredAt: new Date("2026-06-04T00:00:00.000Z"),
sleepStage: "CORE",
},
{
id: "s2",
value: 90,
measuredAt: new Date("2026-06-04T02:00:00.000Z"),
sleepStage: "DEEP",
},
{
id: "s3",
value: 80,
measuredAt: new Date("2026-06-04T04:00:00.000Z"),
sleepStage: "REM",
},
{
id: "s4",
value: 60,
measuredAt: new Date("2026-06-04T05:00:00.000Z"),
sleepStage: "AWAKE",
},
{
id: "s5",
value: 480,
measuredAt: new Date("2026-06-03T23:00:00.000Z"),
sleepStage: "IN_BED",
},
] as never);
const res = await GET(req("kind=sleep&days=30"));
expect(res.status).toBe(200);
@@ -362,11 +386,13 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
const res = await GET(req("kind=garbage&days=0&extraGarbage=fromIos"));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
const annotated = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
expect(annotated, "validation-failed annotate call").toBeTruthy();
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
@@ -377,7 +403,7 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
256,
);
expect(meta.received_shape_excerpt as string).toContain("\"kind\":\"garbage\"");
expect(meta.received_shape_excerpt as string).toContain('"kind":"garbage"');
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
expect(Array.isArray(issues)).toBe(true);
@@ -392,11 +418,13 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
const long = "x".repeat(500);
const res = await GET(req(`kind=garbage&days=0&junk=${long}`));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
const annotated = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
expect((meta.received_shape_excerpt as string).length).toBe(256);
});
@@ -420,11 +448,13 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
);
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
const annotated = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
const excerpt = meta.received_shape_excerpt as string;
+4 -3
View File
@@ -152,7 +152,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
// query map is routed through `redactSensitiveFields` first so any
// future credential-shaped query string (`token`, `apiKey`,
// `csrfState`) redacts to `"[redacted]"` before the truncate.
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(rawQuery));
const payloadDiagnostic = buildPayloadDiagnostic(
redactSensitiveFields(rawQuery),
);
annotate({
action: { name: "measurements.series.validation-failed" },
meta: {
@@ -260,8 +262,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
at: n.measuredAt.toISOString(),
value: Math.round((n.asleepMinutes / 60) * 100) / 100,
secondary: null,
sleepStages:
Object.keys(stageHours).length > 0 ? stageHours : null,
sleepStages: Object.keys(stageHours).length > 0 ? stageHours : null,
};
});
} else if (kind === "bloodPressure") {
@@ -170,10 +170,7 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
// Re-stub deps the outer beforeEach's resetAllMocks cleared.
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
const res = await PUT(
putReq({ endsOn: "2026-12-31" }),
ROUTE_CTX,
);
const res = await PUT(putReq({ endsOn: "2026-12-31" }), ROUTE_CTX);
expect(res.status).toBe(200);
const call = lastUpdateCall();
expect(call.data.endsOn).toBeInstanceOf(Date);
@@ -251,9 +248,7 @@ describe("PUT /api/medications/[id] — schedule replace archives a revision (v1
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(
undefined,
);
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
PREVIOUS_ROW,
] as never);
@@ -379,9 +374,7 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", (
skipped: false,
autoMissed: false,
});
expect(
(arg.where.scheduledFor as { gte: Date }).gte,
).toBeInstanceOf(Date);
expect((arg.where.scheduledFor as { gte: Date }).gte).toBeInstanceOf(Date);
expect(arg.data.deletedAt).toBeInstanceOf(Date);
expect(arg.data.syncVersion).toEqual({ increment: 1 });
});
@@ -394,8 +387,8 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", (
{ scheduledFor: new Date("2026-06-10T18:00:00Z") },
{ scheduledFor: new Date("2026-06-11T06:00:00Z") },
] as never);
vi.mocked(dayKeyForScheduledFor).mockImplementation(
(scheduledFor: Date) => scheduledFor.toISOString().slice(0, 10),
vi.mocked(dayKeyForScheduledFor).mockImplementation((scheduledFor: Date) =>
scheduledFor.toISOString().slice(0, 10),
);
const res = await PUT(
@@ -642,17 +635,15 @@ describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
asNeeded: true,
schedules: [],
} as never);
const res = await PUT(
putReq({ asNeeded: true, schedules: [] }),
ROUTE_CTX,
);
const res = await PUT(putReq({ asNeeded: true, schedules: [] }), ROUTE_CTX);
expect(res.status).toBe(200);
// Wholesale replace ran: delete-all, recreate none.
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
where: { medicationId: "m1" },
});
const updateData = vi.mocked(prisma.medication.update).mock
.calls[0][0] as { data: Record<string, unknown> };
const updateData = vi.mocked(prisma.medication.update).mock.calls[0][0] as {
data: Record<string, unknown>;
};
expect(updateData.data.asNeeded).toBe(true);
expect(updateData.data.schedules).toEqual({ create: [] });
});
@@ -695,8 +686,9 @@ describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
ROUTE_CTX,
);
expect(res.status).toBe(200);
const updateData = vi.mocked(prisma.medication.update).mock
.calls[0][0] as { data: Record<string, unknown> };
const updateData = vi.mocked(prisma.medication.update).mock.calls[0][0] as {
data: Record<string, unknown>;
};
expect(updateData.data.asNeeded).toBe(false);
});
@@ -111,9 +111,7 @@ describe("GET /api/medications/[id]/cadence", () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/cadence?days=14",
),
new NextRequest("http://localhost/api/medications/med-1/cadence?days=14"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
@@ -20,11 +20,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
returnAllZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import {
buildCadenceTimeline,
computeNextDose,
@@ -81,7 +77,9 @@ export const GET = apiHandler(
// Pull intake events covering the window + a small buffer so a dose
// logged at the edge can still pair with its slot.
const bufferMs = 24 * 60 * 60 * 1000;
const from = new Date(asOf.getTime() - windowDays * 24 * 60 * 60 * 1000 - bufferMs);
const from = new Date(
asOf.getTime() - windowDays * 24 * 60 * 60 * 1000 - bufferMs,
);
const events = await prisma.medicationIntakeEvent.findMany({
where: {
medicationId: id,
@@ -82,7 +82,12 @@ const TZ = "UTC";
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, timezone: TZ },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
timezone: TZ,
},
};
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
@@ -340,31 +345,29 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
});
it("35-day-interval med → steps the windows up past 7 / 30 days", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
{
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
startsOn: new Date(Date.now() - 380 * 24 * 60 * 60 * 1000),
endsOn: null,
oneShot: false,
schedules: [
{
id: "sched-1",
windowStart: "10:00",
windowEnd: "11:00",
timesOfDay: ["10:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: 35,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never,
);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
startsOn: new Date(Date.now() - 380 * 24 * 60 * 60 * 1000),
endsOn: null,
oneShot: false,
schedules: [
{
id: "sched-1",
windowStart: "10:00",
windowEnd: "11:00",
timesOfDay: ["10:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: 35,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
takenAt: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
@@ -7,11 +7,7 @@ import {
complianceCacheKey,
type CompliancePayload,
} from "@/lib/medications/compliance-payload";
import {
cachedSwr,
caches,
type ServerCache,
} from "@/lib/cache/server-cache";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
type RouteParams = { params: Promise<{ id: string }> };
@@ -117,7 +117,9 @@ beforeEach(() => {
},
],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
});
describe("GET /api/medications/[id]/dose-history", () => {
@@ -20,11 +20,7 @@ import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
returnAllZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance";
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
@@ -17,7 +17,9 @@ vi.mock("@/lib/db", () => ({
// v1.17.0 — the GET reads the caller's notificationPrefs to resolve
// the reorder-lead-aware low-stock trigger. Default null = the
// documented defaults (alert on, 7-day floor, 10-day lead).
user: { findUnique: vi.fn().mockResolvedValue({ notificationPrefs: null }) },
user: {
findUnique: vi.fn().mockResolvedValue({ notificationPrefs: null }),
},
medicationDoseChange: { create: vi.fn() },
medicationInventoryEvent: { create: vi.fn() },
},
@@ -269,7 +269,12 @@ import { localHmAsUtc } from "@/lib/tz/local-day";
const TZ = "Europe/Berlin";
const SESSION_TZ = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const, timezone: TZ },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
timezone: TZ,
},
};
const ATTR_DAY = new Date("2026-06-05T12:00:00Z");
function at(h: number, m: number): Date {
@@ -541,9 +546,7 @@ describe("PUT — v1.15.18 band re-attribution", () => {
const body = (await res.json()) as {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe(
"medications.intake.force_slot.occupied",
);
expect(body.meta?.errorCode).toBe("medications.intake.force_slot.occupied");
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
});
@@ -588,9 +591,8 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
});
it("an edit into taken consumes on the row (no slot move)", async () => {
const { consumeForIntake, restoreForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
const { consumeForIntake, restoreForIntake } =
await import("@/lib/medications/inventory/consumption");
vi.mocked(prisma.medicationIntakeEvent.findFirst)
// PUT lookup — pending row anchored on the 07:00 slot.
.mockResolvedValueOnce({
@@ -610,7 +612,10 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
skipped: false,
} as never);
const res = await PUT(putReq({ takenAt: at(7, 5).toISOString() }), ROUTE_CTX);
const res = await PUT(
putReq({ takenAt: at(7, 5).toISOString() }),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(consumeForIntake).toHaveBeenCalledTimes(1);
expect(consumeForIntake).toHaveBeenCalledWith(
@@ -624,9 +629,8 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
});
it("an in-place time correction on an already-taken row never consumes (pre-stamp legacy rows must not retro-charge)", async () => {
const { consumeForIntake, restoreForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
const { consumeForIntake, restoreForIntake } =
await import("@/lib/medications/inventory/consumption");
vi.mocked(prisma.medicationIntakeEvent.findFirst)
// PUT lookup — a row already taken BEFORE the edit, stamp NULL
// (pre-v1.16.10: its stock moved through the legacy hook at take
@@ -658,9 +662,8 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
});
it("a taken → skipped edit restores the row's stamp and never consumes", async () => {
const { consumeForIntake, restoreForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
const { consumeForIntake, restoreForIntake } =
await import("@/lib/medications/inventory/consumption");
vi.mocked(prisma.medicationIntakeEvent.findFirst)
.mockResolvedValueOnce({
id: "e1",
@@ -688,9 +691,8 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
});
it("a slot-moving taken edit refunds the old row before tombstoning and consumes the converged row — net one", async () => {
const { consumeForIntake, restoreForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
const { consumeForIntake, restoreForIntake } =
await import("@/lib/medications/inventory/consumption");
vi.mocked(prisma.medicationIntakeEvent.findFirst)
.mockResolvedValueOnce({
id: "e1",
@@ -730,17 +732,16 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
expect.objectContaining({ eventId: "e2" }),
);
// The refund runs BEFORE the tombstone write.
const restoreOrder = vi.mocked(restoreForIntake).mock
const restoreOrder =
vi.mocked(restoreForIntake).mock.invocationCallOrder[0];
const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update).mock
.invocationCallOrder[0];
const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update)
.mock.invocationCallOrder[0];
expect(restoreOrder).toBeLessThan(tombstoneOrder);
});
it("DELETE refunds the row's stamp before the tombstone", async () => {
const { consumeForIntake, restoreForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
const { consumeForIntake, restoreForIntake } =
await import("@/lib/medications/inventory/consumption");
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
id: "e1",
userId: "user-1",
@@ -768,10 +769,10 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
expect.objectContaining({ userId: "user-1", eventId: "e1" }),
);
expect(consumeForIntake).not.toHaveBeenCalled();
const restoreOrder = vi.mocked(restoreForIntake).mock
const restoreOrder =
vi.mocked(restoreForIntake).mock.invocationCallOrder[0];
const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update).mock
.invocationCallOrder[0];
const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update)
.mock.invocationCallOrder[0];
expect(restoreOrder).toBeLessThan(tombstoneOrder);
});
});
@@ -226,7 +226,10 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
}
it("GET surfaces TWO simultaneous validation errors", async () => {
const res = await GET(makeRequest("status=junk&sortBy=garbage"), ROUTE_PARAMS);
const res = await GET(
makeRequest("status=junk&sortBy=garbage"),
ROUTE_PARAMS,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
@@ -284,7 +287,9 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
expect(prisma.auditLog.create).toHaveBeenCalled();
const actions = vi
.mocked(prisma.auditLog.create)
.mock.calls.map((c) => (c[0] as { data: { action: string } }).data.action);
.mock.calls.map(
(c) => (c[0] as { data: { action: string } }).data.action,
);
expect(actions).toContain("medications.intake.list.validation-failed");
expect(actions).toContain("medications.intake.create.validation-failed");
});
@@ -413,9 +418,8 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
});
it("does NOT decrement inventory on a re-post of an already-taken slot (M2)", async () => {
const { consumeForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
const { consumeForIntake } =
await import("@/lib/medications/inventory/consumption");
// Resolver load → scheduled med.
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
SCHEDULED_MED as never,
@@ -463,9 +467,8 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
});
it("DOES decrement inventory on a genuine pending→taken move (M2)", async () => {
const { consumeForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
const { consumeForIntake } =
await import("@/lib/medications/inventory/consumption");
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
SCHEDULED_MED as never,
);
@@ -626,9 +629,8 @@ describe("POST /api/medications/[id]/intake — as-needed (schedule-less) medica
// Inventory consumption fires exactly as for a scheduled medication —
// the seam is medication-level (eventId + intakeAt), no schedule input.
const { consumeForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
const { consumeForIntake } =
await import("@/lib/medications/inventory/consumption");
expect(consumeForIntake).toHaveBeenCalledTimes(1);
expect(consumeForIntake).toHaveBeenCalledWith(
expect.objectContaining({
@@ -674,9 +676,8 @@ describe("POST /api/medications/[id]/intake — as-needed (schedule-less) medica
const res = await POST(postReq({ skipped: true }), ROUTE_PARAMS);
expect(res.status).toBe(201);
const { consumeForIntake, restoreForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
const { consumeForIntake, restoreForIntake } =
await import("@/lib/medications/inventory/consumption");
expect(consumeForIntake).not.toHaveBeenCalled();
expect(restoreForIntake).toHaveBeenCalledTimes(1);
});
@@ -100,11 +100,14 @@ const SESSION_OK = {
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
function postWithBody(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/med-1/intake/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return new NextRequest(
"http://localhost/api/medications/med-1/intake/bulk-delete",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
beforeEach(() => {
@@ -119,7 +122,9 @@ beforeEach(() => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 0,
} as never);
@@ -142,10 +147,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => {
vi.mocked(assertMedicationOwnership).mockResolvedValueOnce(
new Response(null, { status: 404 }) as never,
);
const res = await POST(
postWithBody({ eventIds: ["evt-1"] }),
ROUTE_PARAMS,
);
const res = await POST(postWithBody({ eventIds: ["evt-1"] }), ROUTE_PARAMS);
expect(res.status).toBe(404);
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
});
@@ -226,10 +228,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit",
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
postWithBody({ eventIds: ["evt-a"] }),
ROUTE_PARAMS,
);
const res = await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS);
expect(res.status).toBe(429);
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
@@ -248,9 +247,8 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit",
describe("POST /api/medications/[id]/intake/bulk-delete — v1.16.10 inventory refund", () => {
it("restores each stamped row before the tombstone sweep and ignores unstamped rows", async () => {
const { restoreForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
const { restoreForIntake } =
await import("@/lib/medications/inventory/consumption");
const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0));
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
@@ -275,10 +273,10 @@ describe("POST /api/medications/[id]/intake/bulk-delete — v1.16.10 inventory r
expect.objectContaining({ userId: "user-1", eventId: "evt-stamped" }),
);
// The refund runs BEFORE the soft-delete sweep.
const restoreOrder = vi.mocked(restoreForIntake).mock
const restoreOrder =
vi.mocked(restoreForIntake).mock.invocationCallOrder[0];
const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.invocationCallOrder[0];
const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany)
.mock.invocationCallOrder[0];
expect(restoreOrder).toBeLessThan(sweepOrder);
});
});
Loaded 100 of 1000 files, more files were not shown because too many files have changed in this diff. Show more