mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged
Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
This commit is contained in:
1 parent
324d4bb411
commit
6c7fc7dc84
167 files changed
+11233
-371
No files matched your search
@@ -30,9 +30,27 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 15 * 60 * 1000,
|
||||
}),
|
||||
// v1.4.43 W13 M-4 — the route now routes through the auth-surface
|
||||
// wrapper. Default to a clean per-IP result.
|
||||
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 15 * 60 * 1000,
|
||||
ip: "203.0.113.1",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn(
|
||||
(raw: string) =>
|
||||
`hash-${Buffer.from(raw, "utf-8")
|
||||
.toString("hex")
|
||||
.slice(0, 16)
|
||||
.padStart(16, "0")}`,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
@@ -44,7 +62,10 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST } from "@/app/api/auth/check-user/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkAuthSurfaceRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
|
||||
interface CheckUserBody {
|
||||
data: {
|
||||
@@ -69,6 +90,12 @@ beforeEach(() => {
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 15 * 60 * 1000,
|
||||
});
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 15 * 60 * 1000,
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -149,11 +176,12 @@ describe("POST /api/auth/check-user", () => {
|
||||
});
|
||||
|
||||
it("returns 429 when the per-IP rate-limit is exhausted", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 15 * 60 * 1000,
|
||||
});
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
const res = await POST(makeRequest({ identifier: "anyone@example.com" }));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* v1.4.43 W11-M5 — dashboard tile-strip skeleton during slow slim
|
||||
* fetches.
|
||||
*
|
||||
* Pre-fix the strip only painted once `trendCards.length > 0`. When
|
||||
* both slim + thick `/api/analytics` slices lagged (cache eviction,
|
||||
* cold start) the user saw the page header + zero tiles + then a
|
||||
* burst of tiles 9 s later. The fix paints a layout-stable silhouette
|
||||
* keyed off the user's configured tile count
|
||||
* (`layout.widgets.filter(w => w.tileVisible ?? w.visible).length`)
|
||||
* while `analyticsSlimQuery.isLoading` is still in flight, so the
|
||||
* page reserves the strip's footprint immediately and swaps to the
|
||||
* real tiles the moment slim resolves.
|
||||
*
|
||||
* Textual pins keep the contract honest — render-mounting the
|
||||
* dashboard page would haul in TanStack-Query, auth, i18n, layout
|
||||
* and analytics scaffolding for a guard a substring scan already
|
||||
* proves.
|
||||
*/
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
|
||||
|
||||
describe("v1.4.43 W11 — dashboard tile-strip skeleton", () => {
|
||||
it("derives `configuredTileCount` from the resolved layout", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
expect(src).toMatch(
|
||||
/const\s+configuredTileCount\s*=\s*layout\.widgets\.filter\([\s\S]*?w\.tileVisible\s*\?\?\s*w\.visible[\s\S]*?\)\.length;/,
|
||||
);
|
||||
});
|
||||
|
||||
it("gates the skeleton on slim-analytics loading + empty trend cards + configured tiles", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
expect(src).toMatch(
|
||||
/const\s+showTileStripSkeleton\s*=\s*[\s\S]*?trendCards\.length\s*===\s*0[\s\S]*?analyticsSlimQuery\.isLoading[\s\S]*?configuredTileCount\s*>\s*0/,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the skeleton with the same auto-fit grid track the real strip uses", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
expect(src).toMatch(
|
||||
/showTileStripSkeleton\s*&&\s*\(\s*<div[\s\S]*?\[grid-template-columns:repeat\(auto-fit,minmax\(min\(100%,11rem\),1fr\)\)\]/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keys the skeleton card count off configuredTileCount", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
expect(src).toMatch(
|
||||
/Array\.from\(\{\s*length:\s*configuredTileCount\s*\}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("marks the skeleton aria-hidden + carries motion-reduce", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
const skeletonBlock = src.match(
|
||||
/showTileStripSkeleton\s*&&[\s\S]*?<\/div>\s*\)\}/,
|
||||
);
|
||||
expect(skeletonBlock).not.toBeNull();
|
||||
const block = skeletonBlock?.[0] ?? "";
|
||||
expect(block).toContain('aria-hidden="true"');
|
||||
expect(block).toContain("motion-reduce:animate-none");
|
||||
});
|
||||
|
||||
it("suppresses the EmptyState while the skeleton is showing", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
expect(src).toMatch(
|
||||
/trendCards\.length\s*===\s*0\s*&&\s*charts\.length\s*===\s*0\s*&&\s*!showTileStripSkeleton/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* v1.4.43 QoL (L1) — pin the tightened 404 / global-error copy so a
|
||||
* future translator does not re-introduce the v1.4.27 marketing
|
||||
* paragraph. The 404 page now ships with i18n keys (`notFound.*`)
|
||||
* across all six locales; the global-error boundary remains static
|
||||
* bilingual (no i18n provider available at that level).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const LOCALES = ["en", "de", "fr", "es", "it", "pl"] as const;
|
||||
|
||||
interface Messages {
|
||||
notFound: {
|
||||
title: string;
|
||||
backToDashboard: string;
|
||||
};
|
||||
}
|
||||
|
||||
function load(locale: string): Messages {
|
||||
const raw = readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8");
|
||||
return JSON.parse(raw) as Messages;
|
||||
}
|
||||
|
||||
describe("notFound copy", () => {
|
||||
it.each(LOCALES)("%s: ships title + backToDashboard keys", (locale) => {
|
||||
const messages = load(locale);
|
||||
expect(messages.notFound.title.trim().length).toBeGreaterThan(0);
|
||||
expect(messages.notFound.backToDashboard.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(LOCALES)(
|
||||
"%s: title is tight — under 60 characters, ends with a period",
|
||||
(locale) => {
|
||||
const messages = load(locale);
|
||||
// Tightened copy is a single short statement, e.g. "Diese Seite
|
||||
// existiert nicht." The 60-char ceiling pins out a future return
|
||||
// of the verbose marketing block.
|
||||
expect(messages.notFound.title.length).toBeLessThanOrEqual(60);
|
||||
expect(messages.notFound.title).toMatch(/\.$/);
|
||||
},
|
||||
);
|
||||
|
||||
it("de: matches the tightened copy exactly", () => {
|
||||
const messages = load("de");
|
||||
expect(messages.notFound.title).toBe("Diese Seite existiert nicht.");
|
||||
});
|
||||
|
||||
it("en: matches the tightened copy exactly", () => {
|
||||
const messages = load("en");
|
||||
expect(messages.notFound.title).toBe("This page doesn't exist.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("global-error bilingual copy", () => {
|
||||
// The global-error boundary cannot reach the i18n provider (root
|
||||
// layout has failed). The v1.4.43 audit asked for a bilingual
|
||||
// lockup so a German user does not read pure English in a bad-
|
||||
// state moment. Read the file and assert both languages appear.
|
||||
const globalErrorPath = join(ROOT, "src/app/global-error.tsx");
|
||||
const source = readFileSync(globalErrorPath, "utf8");
|
||||
|
||||
it("ships the German leading phrase next to the English fallback", () => {
|
||||
expect(source).toContain("Etwas ist schiefgegangen");
|
||||
expect(source).toContain("Something went wrong");
|
||||
});
|
||||
|
||||
it("ships the German error subtitle next to the English fallback", () => {
|
||||
expect(source).toContain("Ein kritischer Fehler ist aufgetreten");
|
||||
// The JSX-formatter wraps the English fallback across two lines, so
|
||||
// collapse all whitespace before searching.
|
||||
const collapsed = source.replace(/\s+/g, " ");
|
||||
expect(collapsed).toContain("A critical error occurred");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on PATCH /api/admin/feedback/[id].
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
feedback: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_ADMIN = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "admin", role: "ADMIN" as const },
|
||||
};
|
||||
|
||||
function patchReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/feedback/f1", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const ROUTE_CTX = { params: Promise.resolve({ id: "f1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_ADMIN as never);
|
||||
});
|
||||
|
||||
describe("PATCH /api/admin/feedback/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad `status` enum + adminNote too long.
|
||||
const res = await PATCH(
|
||||
patchReq({ status: "junk", adminNote: "x".repeat(10000) }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
|
||||
// The schema has only two knobs (status + adminNote), so the strict
|
||||
// 3-issue ceiling isn't reachable. Multi-issue contract is pinned at
|
||||
// ≥ 2 by the case above.
|
||||
const res = await PATCH(
|
||||
patchReq({ status: 999, adminNote: 123 }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
getClientIp,
|
||||
} from "@/lib/api-response";
|
||||
@@ -25,7 +26,8 @@ export const PATCH = apiHandler(
|
||||
|
||||
const parsed = updateFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const before = await prisma.feedback.findUnique({ where: { id } });
|
||||
|
||||
@@ -505,4 +505,46 @@ describe("PUT /api/admin/settings", () => {
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad `registrationEnabled` (string not boolean) + bad
|
||||
// `reminderLateMinutes` (string not int).
|
||||
const res = await PUT(
|
||||
jsonReq({
|
||||
registrationEnabled: "string",
|
||||
reminderLateMinutes: "string",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
const res = await PUT(
|
||||
jsonReq({
|
||||
registrationEnabled: "string",
|
||||
reminderLateMinutes: "string",
|
||||
reminderMissedMinutes: "string",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -154,4 +154,47 @@ describe("PUT /api/admin/settings/assistant-flags", () => {
|
||||
const res = await PUT(putReq({ assistantPotatoEnabled: false }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
// Two bad-typed flags.
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
assistantEnabled: "string",
|
||||
assistantCoachEnabled: 999,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
assistantEnabled: "string",
|
||||
assistantCoachEnabled: 999,
|
||||
assistantBriefingEnabled: "string",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -124,7 +125,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = assistantFlagsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const updates: Partial<AssistantFlagsRow> = {};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -71,7 +72,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = adminSettingsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
|
||||
@@ -205,4 +205,40 @@ describe("PUT /api/admin/users/[id]", () => {
|
||||
const res = await PUT(r, params("u1"));
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
|
||||
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad username (too short) + bad role enum.
|
||||
const res = await PUT(
|
||||
jsonReq({ username: "x", role: "GOD" }),
|
||||
params("u1"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
const res = await PUT(
|
||||
jsonReq({ username: "x", email: "not-an-email", role: "GOD" }),
|
||||
params("u1"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -38,7 +39,8 @@ export const PUT = apiHandler(
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = updateUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// The real `hashToken` is an HMAC-SHA256 (one-way + key-dependent); the
|
||||
// shape only matters here, so a deterministic stand-in keeps the test
|
||||
// stable without standing up the env-keyed CipherSuite. The PII check
|
||||
// below asserts the raw identifier never appears in the audit payload.
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn(
|
||||
(raw: string) =>
|
||||
`hash-${Buffer.from(raw, "utf-8")
|
||||
.toString("hex")
|
||||
.slice(0, 16)
|
||||
.padStart(16, "0")}`,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 30, resetAt: 0 }),
|
||||
// v1.4.43 W13 M-4 — `check-user` now routes through the auth-surface
|
||||
// wrapper so the trust-violation branch can swap to a tighter global
|
||||
// bucket. Default to a clean per-IP result.
|
||||
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 30,
|
||||
resetAt: 0,
|
||||
ip: "203.0.113.1",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
|
||||
function expectedHash(identifier: string): string {
|
||||
return (hashToken as unknown as (s: string) => string)(identifier);
|
||||
}
|
||||
|
||||
function makeRequest(body: { identifier: string }): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/check-user", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.user.findFirst).mockReset();
|
||||
vi.mocked(auditLog).mockReset();
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
|
||||
it("writes an audit row for the not_found branch with identifier_hash (never raw)", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
|
||||
const res = await POST(makeRequest({ identifier: "ghost@example.com" }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(auditLog).mock.calls[0];
|
||||
expect(args[0]).toBe("auth.check-user");
|
||||
expect(args[1]).toEqual({
|
||||
ipAddress: "203.0.113.1",
|
||||
details: {
|
||||
branch: "not_found",
|
||||
identifier_hash: expectedHash("ghost@example.com"),
|
||||
},
|
||||
});
|
||||
// The raw identifier must not leak into the details — every other
|
||||
// /api/auth/* audit row already hashes; the check-user route follows
|
||||
// the same shape.
|
||||
const details = args[1]?.details as Record<string, unknown> | undefined;
|
||||
expect(details).not.toHaveProperty("identifier");
|
||||
expect(JSON.stringify(details)).not.toContain("ghost@example.com");
|
||||
});
|
||||
|
||||
it("writes an audit row for the passkey_only branch with userId attached", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
id: "user-1",
|
||||
passwordHash: null,
|
||||
_count: { passkeys: 2 },
|
||||
} as never);
|
||||
|
||||
const res = await POST(makeRequest({ identifier: "marc" }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(auditLog).mock.calls[0][1]).toEqual({
|
||||
userId: "user-1",
|
||||
ipAddress: "203.0.113.1",
|
||||
details: {
|
||||
branch: "passkey_only",
|
||||
identifier_hash: expectedHash("marc"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("writes an audit row for the email_fallback branch with userId attached", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
id: "user-2",
|
||||
passwordHash: "$argon2id$...",
|
||||
_count: { passkeys: 0 },
|
||||
} as never);
|
||||
|
||||
const res = await POST(makeRequest({ identifier: "marc@example.com" }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const args = vi.mocked(auditLog).mock.calls[0];
|
||||
expect(args[1]?.details).toEqual({
|
||||
branch: "email_fallback",
|
||||
identifier_hash: expectedHash("marc@example.com"),
|
||||
});
|
||||
expect(args[1]?.userId).toBe("user-2");
|
||||
});
|
||||
|
||||
it("writes an audit row for the exists branch (no passkey + no password)", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
id: "user-3",
|
||||
passwordHash: null,
|
||||
_count: { passkeys: 0 },
|
||||
} as never);
|
||||
|
||||
const res = await POST(makeRequest({ identifier: "stub" }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const args = vi.mocked(auditLog).mock.calls[0];
|
||||
expect(args[1]?.details).toEqual({
|
||||
branch: "exists",
|
||||
identifier_hash: expectedHash("stub"),
|
||||
});
|
||||
expect(args[1]?.userId).toBe("user-3");
|
||||
});
|
||||
});
|
||||
@@ -37,10 +37,15 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson, getClientIp } from "@/lib/api-response";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
@@ -57,12 +62,14 @@ export type CheckUserBranch =
|
||||
| "exists";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(
|
||||
`auth:check-user:${ip}`,
|
||||
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:check-user",
|
||||
30,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ data: null, error: "Too many requests. Please try again later." },
|
||||
@@ -92,8 +99,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.43 W13 M-1 — persist a hashed-identifier audit row so an admin
|
||||
// can later answer "did anyone enumerate accounts last week?". The
|
||||
// wide-event `annotate()` log is sampled and short-retention; the
|
||||
// audit ledger is durable. We hash the identifier (same shape as the
|
||||
// `auth.login.failed` row) so the literal email/username never lands
|
||||
// in `AuditLog.details` — the v1.4.20 retroactive PII directive bars
|
||||
// raw user-typed strings from operator-readable artefacts.
|
||||
const identifierHash = hashToken(identifier);
|
||||
|
||||
if (!user) {
|
||||
annotate({ action: { name: "auth.check-user" }, meta: { branch: "not_found" } });
|
||||
await auditLog("auth.check-user", {
|
||||
ipAddress: ip,
|
||||
details: { branch: "not_found", identifier_hash: identifierHash },
|
||||
});
|
||||
return apiSuccess({
|
||||
branch: "not_found" satisfies CheckUserBranch,
|
||||
hasPasskey: false,
|
||||
@@ -110,5 +130,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
else branch = "exists";
|
||||
|
||||
annotate({ action: { name: "auth.check-user" }, meta: { branch } });
|
||||
await auditLog("auth.check-user", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { branch, identifier_hash: identifierHash },
|
||||
});
|
||||
return apiSuccess({ branch, hasPasskey, hasPassword });
|
||||
});
|
||||
@@ -28,6 +28,15 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
|
||||
// v1.4.43 W13 M-4 — auth surfaces now route through the wrapper that
|
||||
// returns `{ip, ...rate-limit-result}`. Default to a clean per-IP
|
||||
// result so the existing tests don't need to set every IP up.
|
||||
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: 0,
|
||||
ip: "203.0.113.1",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
@@ -55,7 +64,11 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkAuthSurfaceRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
@@ -94,6 +107,12 @@ beforeEach(() => {
|
||||
remaining: 5,
|
||||
reset: 0,
|
||||
} as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: 0,
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
|
||||
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
|
||||
@@ -33,6 +33,12 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
|
||||
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
reset: 0,
|
||||
ip: "1.2.3.4",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
@@ -61,7 +67,11 @@ import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkAuthSurfaceRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
|
||||
const TYPED_EMAIL = "leaked-identifier@example.com";
|
||||
@@ -84,6 +94,12 @@ beforeEach(() => {
|
||||
remaining: 5,
|
||||
reset: 0,
|
||||
} as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
reset: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
|
||||
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
@@ -6,10 +6,9 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
@@ -22,8 +21,16 @@ import {
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(`auth:login:${ip}`, 5, 15 * 60 * 1000);
|
||||
// v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter
|
||||
// global bucket when the trust chain is misconfigured; otherwise it
|
||||
// is byte-equivalent to the previous per-IP `auth:login:{ip}` key.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:login",
|
||||
5,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(
|
||||
`auth:passkey-login-options:${ip}`,
|
||||
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:passkey-login-options",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
|
||||
@@ -24,6 +24,9 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
// v1.4.43 W13 M-4 — passkey/login-verify now uses the auth-surface
|
||||
// wrapper. Default to a clean per-IP result.
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
@@ -50,7 +53,10 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkAuthSurfaceRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
|
||||
const FAKE_USER = {
|
||||
id: "user-1",
|
||||
@@ -81,6 +87,12 @@ beforeEach(() => {
|
||||
remaining: 9,
|
||||
reset: 0,
|
||||
} as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: 0,
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
vi.mocked(verifyAuthentication).mockResolvedValue({
|
||||
verification: { verified: true },
|
||||
passkey: { userId: "user-1" },
|
||||
|
||||
@@ -4,12 +4,11 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -23,12 +22,14 @@ import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(
|
||||
`auth:passkey-verify:${ip}`,
|
||||
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:passkey-verify",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip;
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please wait 15 minutes.", 429);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/auth/password.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
destroyAllSessions: vi.fn().mockResolvedValue(undefined),
|
||||
createSession: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
verifyPassword: vi.fn().mockResolvedValue(true),
|
||||
hashPassword: vi.fn().mockResolvedValue("hashed"),
|
||||
checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn().mockResolvedValue("en"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
email: "t@example.com",
|
||||
role: "USER" as const,
|
||||
passwordHash: "h",
|
||||
onboardingCompletedAt: new Date(),
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
});
|
||||
|
||||
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" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Both empty + extra invalid type.
|
||||
const res = await POST(
|
||||
postReq({ currentPassword: "", newPassword: 123 }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
// currentPassword empty (1) + newPassword wrong type (1) — might
|
||||
// collapse to 2 issues. We pin the contract on ≥ 2.
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -36,7 +37,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = changePasswordSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = parsed.data;
|
||||
|
||||
@@ -7,12 +7,11 @@ import { apiHandler } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { resolveTokenPolicy } from "@/lib/auth/native-client";
|
||||
import {
|
||||
rotateRefreshToken,
|
||||
@@ -20,8 +19,14 @@ import {
|
||||
} from "@/lib/auth/refresh-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(`auth:refresh:${ip}`, 60, 15 * 60 * 1000);
|
||||
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:refresh",
|
||||
60,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many refresh attempts. Please retry later.", 429);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/auth/register.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn(),
|
||||
},
|
||||
appSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue({ registrationEnabled: true }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
hashPassword: vi.fn().mockResolvedValue("hashed"),
|
||||
checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn().mockResolvedValue("en"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
isValidTimezone: vi.fn().mockReturnValue(true),
|
||||
resolveServerDefaultTimezone: vi.fn().mockResolvedValue("Europe/Berlin"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad email + too-short username.
|
||||
const res = await postPathThrough({ email: "not-an-email", username: "x" });
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Bad email + bad username + bad password.
|
||||
const res = await postPathThrough({
|
||||
email: "not-an-email",
|
||||
username: "x",
|
||||
password: "weak",
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
async function postPathThrough(body: unknown): Promise<Response> {
|
||||
return POST(postReq(body));
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
@@ -21,8 +21,15 @@ import {
|
||||
} from "@/lib/tz/resolver";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(`auth:register:${ip}`, 5, 15 * 60 * 1000);
|
||||
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
|
||||
// is misconfigured; otherwise byte-equivalent per-IP semantics.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:register",
|
||||
5,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -60,7 +67,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const parsed = registerSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { email, username, password, timezone: timezoneInput } = parsed.data;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/bugreport.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (s: string) => s.replace(/^enc-/, ""),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/bugreport", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
githubIssueTokenEncrypted: "enc-token",
|
||||
githubIssueRepo: "owner/repo",
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/bugreport — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// description too short + screenshot doesn't match data-URL regex.
|
||||
const res = await POST(
|
||||
postReq({ description: "tiny", screenshot: "not-a-data-url" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
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 }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -83,7 +84,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = bugReportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { description, screenshot } = parsed.data;
|
||||
|
||||
@@ -167,4 +167,44 @@ describe("POST /api/consent/ai", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.4.43 W6 — multi-issue 400 envelope (consent uses 400)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Bad kind enum + empty artefact.
|
||||
const res = await POST(
|
||||
req({
|
||||
kind: "junk",
|
||||
artefact: "",
|
||||
signedAt: "2026-05-18T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(
|
||||
req({ kind: "junk", artefact: "", signedAt: "not-iso" }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -390,4 +390,48 @@ describe("append-only invariant", () => {
|
||||
};
|
||||
expect(body.data.receipt?.id).toBe("rcpt-2");
|
||||
});
|
||||
|
||||
describe("v1.4.43 W6 — multi-issue 400 envelope (consent uses 400)", () => {
|
||||
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",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
// The latest query only has a single `kind` knob, so the strict
|
||||
// 2-issue case can't be reached. The shared helper itself is
|
||||
// covered exhaustively by api-response-zod.test.ts; this case
|
||||
// pins the route's new envelope on ≥ 1 issue.
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(1);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
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" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,10 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -38,7 +41,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 400);
|
||||
// v1.4.43 W6 — multi-issue 400 (consent routes use 400 not 422).
|
||||
return returnAllZodIssues(parsed.error, 400);
|
||||
}
|
||||
|
||||
const { kind } = parsed.data;
|
||||
@@ -86,7 +90,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 400);
|
||||
// v1.4.43 W6 — multi-issue 400 (consent routes use 400 not 422).
|
||||
return returnAllZodIssues(parsed.error, 400);
|
||||
}
|
||||
|
||||
const { kind } = parsed.data;
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { consentPostBody } from "@/lib/validations/consent";
|
||||
@@ -23,7 +27,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = consentPostBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 400);
|
||||
// v1.4.43 W6 — multi-issue 400 (consent routes use 400 not 422).
|
||||
return returnAllZodIssues(parsed.error, 400);
|
||||
}
|
||||
|
||||
const { kind, artefact, signedAt } = parsed.data;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on PUT
|
||||
* /api/dashboard/chart-overlay-prefs.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
toJson: (v: unknown) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserDashboardWidgets: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function putReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/dashboard/chart-overlay-prefs", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
__resetAuditDedupMemoForTests();
|
||||
});
|
||||
|
||||
describe("PUT /api/dashboard/chart-overlay-prefs — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad `chartKey` enum + `prefs.showTrendIndicator` not a boolean.
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
chartKey: "not-a-known-chart",
|
||||
prefs: {
|
||||
showTrendIndicator: "string",
|
||||
showTrendArrow: true,
|
||||
showTargetRange: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Bad chartKey + bad showTrendIndicator + bad comparisonBaseline.
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
chartKey: "not-a-chart",
|
||||
prefs: {
|
||||
showTrendIndicator: "string",
|
||||
showTrendArrow: true,
|
||||
showTargetRange: true,
|
||||
comparisonBaseline: "tomorrow",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes a dashboard.chart-overlay.validation-failed audit row", async () => {
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
chartKey: "not-a-chart",
|
||||
prefs: {
|
||||
showTrendIndicator: "string",
|
||||
showTrendArrow: true,
|
||||
showTargetRange: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.action).toBe("dashboard.chart-overlay.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
chartKey: "not-a-chart",
|
||||
prefs: {
|
||||
showTrendIndicator: "string",
|
||||
showTrendArrow: true,
|
||||
showTargetRange: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,13 @@
|
||||
* same request.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
@@ -49,7 +55,34 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = prefsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — sibling of `/api/dashboard/widgets`; the per-chart
|
||||
// overlay popover hits this on every toggle so the multi-issue
|
||||
// envelope matches widgets exactly. Audit breadcrumb keyed
|
||||
// `dashboard.chart-overlay.validation-failed`, deduped via the
|
||||
// shared `shouldEmitAuditRow` 60 s `(userId, action)` window so a
|
||||
// misbehaving iOS client looping the popover cannot flood the
|
||||
// audit ledger.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "dashboard.chart-overlay.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
if (
|
||||
shouldEmitAuditRow(user.id, "dashboard.chart-overlay.validation-failed")
|
||||
) {
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "dashboard.chart-overlay.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
}
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// Read-modify-write inside a Serializable transaction so two
|
||||
|
||||
@@ -42,39 +42,11 @@ import type { NextRequest } from "next/server";
|
||||
// the enum from `DASHBOARD_WIDGET_IDS` so the two lists cannot drift.
|
||||
const widgetIdEnum = z.enum(DASHBOARD_WIDGET_IDS);
|
||||
|
||||
// v1.4.43 B2 — in-process dedup memo for the validation-failed audit row.
|
||||
// A misbehaving iOS client looping on a 422 can otherwise write thousands
|
||||
// of identical audit rows per minute. We dedup on `(userId, action)` with
|
||||
// a 60 s window: only the first 422 inside the window writes; subsequent
|
||||
// 422s still return the full multi-issue envelope but skip the breadcrumb.
|
||||
// In-memory only — a process restart resets the map, which is the safe
|
||||
// failure mode (worst case: one extra audit row per restart). Cluster
|
||||
// deployments still get one row per process per minute, which is fine for
|
||||
// the operator-grep use case the audit row exists for.
|
||||
const AUDIT_DEDUP_WINDOW_MS = 60_000;
|
||||
const auditDedupMemo = new Map<string, number>();
|
||||
|
||||
function shouldEmitAuditRow(userId: string, action: string, now: number): boolean {
|
||||
const key = `${userId}:${action}`;
|
||||
const last = auditDedupMemo.get(key);
|
||||
if (last !== undefined && now - last < AUDIT_DEDUP_WINDOW_MS) {
|
||||
return false;
|
||||
}
|
||||
auditDedupMemo.set(key, now);
|
||||
// Opportunistic GC — drop entries older than the window on every miss so
|
||||
// the map cannot grow unbounded across a long-running process.
|
||||
if (auditDedupMemo.size > 512) {
|
||||
for (const [k, t] of auditDedupMemo) {
|
||||
if (now - t >= AUDIT_DEDUP_WINDOW_MS) auditDedupMemo.delete(k);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @internal — exported for the dedup unit test only. */
|
||||
export function __resetAuditDedupMemoForTests(): void {
|
||||
auditDedupMemo.clear();
|
||||
}
|
||||
// v1.4.43 B2 — dedup helper extracted to `src/lib/audit-dedup.ts` so both
|
||||
// `/api/dashboard/widgets` and `/api/dashboard/chart-overlay-prefs` share
|
||||
// the same 60 s `(userId, action)` window.
|
||||
export { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
|
||||
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
|
||||
|
||||
const layoutSchema = z.object({
|
||||
version: z.literal(1),
|
||||
|
||||
@@ -12,6 +12,9 @@ vi.mock("@/lib/db", () => ({
|
||||
notificationChannel: {
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -67,6 +70,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({
|
||||
id: "ch-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/devices", () => {
|
||||
@@ -231,3 +235,74 @@ describe("POST /api/devices", () => {
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/devices — 422 multi-issue envelope (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors under details.issues", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Missing `token` (required) + invalid `apnsEnvironment` enum.
|
||||
const res = await POST(
|
||||
req({ bundleId: "io.healthlog.app", apnsEnvironment: "tomorrow" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Missing `token` + invalid `apnsToken` (non-hex) + invalid `bundleId` (number, not string).
|
||||
const res = await POST(
|
||||
req({
|
||||
bundleId: 42,
|
||||
apnsToken: "not-hex-z",
|
||||
apnsEnvironment: "sandbox",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; code: string }> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes one audit-ledger row keyed devices.register.validation-failed", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(
|
||||
req({ bundleId: "io.healthlog.app", apnsEnvironment: "tomorrow" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string; details: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("devices.register.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await POST(
|
||||
req({ bundleId: "io.healthlog.app", apnsEnvironment: "tomorrow" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,13 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -71,7 +77,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = deviceSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — iOS device-registration multi-issue 422 + audit
|
||||
// breadcrumb. APNs-token + bundleId + environment + model + locale
|
||||
// can all fail simultaneously on a misconfigured client build.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "devices.register.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "devices.register.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const {
|
||||
|
||||
@@ -103,3 +103,53 @@ describe("POST /api/feedback bug-report toggle gate", () => {
|
||||
expect(prisma.feedback.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/feedback — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
// subject too short + description too short.
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "x",
|
||||
description: "short",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
// bad subject + bad description + bad screenshot.
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "x",
|
||||
description: "short",
|
||||
screenshot: "not-a-data-url",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
getClientIp,
|
||||
} from "@/lib/api-response";
|
||||
@@ -45,7 +46,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = createFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const feedback = await prisma.feedback.create({
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/ingest/medication.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
apiToken: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
medication: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/ingest/medication", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer hlk_test_token",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "tok-1",
|
||||
userId: "user-1",
|
||||
permissions: ["*"],
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// externalIntakeSchema requires medicationName (min 1, max 200) and
|
||||
// idempotencyKey (string, max 128); takenAt iso optional. Sending
|
||||
// `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" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// medicationName not a string (type-mismatch) + idempotencyKey too
|
||||
// long (>128 chars) + takenAt bad iso → 3 distinct issues.
|
||||
const res = await POST(
|
||||
postReq({
|
||||
medicationName: 123,
|
||||
idempotencyKey: "x".repeat(200),
|
||||
takenAt: "not-iso",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes the audit-ledger row keyed ingest.medication.validation-failed", async () => {
|
||||
const res = await POST(postReq({ medicationName: "" }));
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("ingest.medication.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await POST(postReq({ medicationName: "" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -92,7 +94,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = externalIntakeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — external native ingest is a bulk-source; surface
|
||||
// every Zod issue so a Telegram-bridge or HomeAssistant
|
||||
// integration sees the full validation shape at once. Audit
|
||||
// breadcrumb keyed `ingest.medication.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "ingest.medication.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: apiToken.userId,
|
||||
action: "ingest.medication.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { medicationName, takenAt, idempotencyKey } = parsed.data;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { NextResponse, type NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
|
||||
@@ -194,6 +195,46 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
// v1.4.43 W13 M-3 — replay-injection guard. `detectRefusal` runs
|
||||
// only on the inbound `message` per turn, so an injection that
|
||||
// slipped past the regex bank on a previous turn would re-enter
|
||||
// the prompt every reply. Re-run the detector against every
|
||||
// user-turn re-loaded from DB; on a hit, short-circuit the SSE
|
||||
// with a refusal AND drop an `insights.coach.replay_injection`
|
||||
// row so the failure case is observable. The audit row carries
|
||||
// the conversation id (server-owned), the turn index (no PII)
|
||||
// and the matched reason — never the message content. v1.4.43
|
||||
// W10 simplifier-L-2 — action name follows the `<surface>.<verb>`
|
||||
// convention (no `audit.` prefix; no `.replay-injection` dash).
|
||||
for (let i = 0; i < priorTurns.length; i++) {
|
||||
const turn = priorTurns[i];
|
||||
if (turn.role !== "user") continue;
|
||||
const replayed = detectRefusal({ message: turn.content, locale });
|
||||
if (!replayed.refuse) continue;
|
||||
annotate({
|
||||
action: { name: "insights.coach.replay_injection" },
|
||||
meta: { reason: replayed.reason, turnIndex: i },
|
||||
});
|
||||
await auditLog("insights.coach.replay_injection", {
|
||||
userId,
|
||||
details: {
|
||||
conversationId: existing.id,
|
||||
turnIndex: i,
|
||||
reason: replayed.reason,
|
||||
},
|
||||
});
|
||||
return streamRefusal({
|
||||
userId,
|
||||
conversationId: existing.id,
|
||||
message,
|
||||
refusalText:
|
||||
replayed.message ??
|
||||
(locale === "de"
|
||||
? "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung."
|
||||
: "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const created = await createConversation({ userId, title: message });
|
||||
workingConversationId = created.id;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
// v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths.
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
toJson: <T,>(v: T) => v,
|
||||
}));
|
||||
@@ -45,6 +47,7 @@ beforeEach(() => {
|
||||
healthKitLastSyncedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
@@ -116,3 +119,83 @@ describe("PATCH /api/integrations/healthkit", () => {
|
||||
expect(ids).not.toContain("totallyUnknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/integrations/healthkit — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
function patchReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/integrations/healthkit", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Two bad entries: first with bad direction, second with empty id.
|
||||
const res = await PATCH(
|
||||
patchReq({
|
||||
entries: [
|
||||
{ id: "bodyMass", direction: "JUNK" },
|
||||
{ id: "", direction: "readOnly" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PATCH(
|
||||
patchReq({
|
||||
entries: [
|
||||
{ id: "bodyMass", direction: "JUNK1" },
|
||||
{ id: "", direction: "JUNK2" },
|
||||
{ id: "weight", direction: "JUNK3" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes the audit-ledger row keyed integrations.healthkit.validation-failed", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PATCH(
|
||||
patchReq({ entries: [{ id: "bodyMass", direction: "JUNK" }] }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.action).toBe("integrations.healthkit.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await PATCH(
|
||||
patchReq({ entries: [{ id: "bodyMass", direction: "JUNK" }] }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,12 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -139,7 +144,26 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — iOS HealthKit ingest is a hot path. Multi-issue
|
||||
// 422 + audit breadcrumb so the iOS Sync engine sees the full
|
||||
// diff and stops iterating one field at a time.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "integrations.healthkit.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "integrations.healthkit.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* v1.4.43 W14 — POST /api/integrations/withings/resume
|
||||
*
|
||||
* Three pins:
|
||||
* 1. Happy path: a parked row resumes, response carries
|
||||
* `wasParked: true`.
|
||||
* 2. Idempotent: a connected row resumes silently, response carries
|
||||
* `wasParked: false`.
|
||||
* 3. Rate-limit: a 6th call within the window returns 429 with the
|
||||
* `rate_limited_self` error code.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
resumeIntegrationFromPark: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { resumeIntegrationFromPark } from "@/lib/integrations/status";
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/integrations/withings/resume", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(resumeIntegrationFromPark).mockReset();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/integrations/withings/resume", () => {
|
||||
it("clears a parked integration and reports wasParked: true", async () => {
|
||||
vi.mocked(resumeIntegrationFromPark).mockResolvedValueOnce({
|
||||
wasParked: true,
|
||||
});
|
||||
|
||||
const response = await POST();
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiEnvelope<{
|
||||
resumed: boolean;
|
||||
wasParked: boolean;
|
||||
}>;
|
||||
expect(body.data).toEqual({ resumed: true, wasParked: true });
|
||||
expect(resumeIntegrationFromPark).toHaveBeenCalledWith("u-1", "withings");
|
||||
});
|
||||
|
||||
it("returns wasParked: false when the row was already connected", async () => {
|
||||
vi.mocked(resumeIntegrationFromPark).mockResolvedValueOnce({
|
||||
wasParked: false,
|
||||
});
|
||||
|
||||
const response = await POST();
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiEnvelope<{
|
||||
resumed: boolean;
|
||||
wasParked: boolean;
|
||||
}>;
|
||||
expect(body.data).toEqual({ resumed: true, wasParked: false });
|
||||
});
|
||||
|
||||
it("returns 429 with rate_limited_self when the rate limit is exhausted", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
|
||||
const response = await POST();
|
||||
expect(response.status).toBe(429);
|
||||
const body = (await response.json()) as ApiEnvelope<unknown> & {
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.error).toBeTruthy();
|
||||
expect(body.meta?.errorCode).toBe("rate_limited_self");
|
||||
expect(resumeIntegrationFromPark).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* v1.4.43 W14 — manual resume endpoint for a parked Withings
|
||||
* integration.
|
||||
*
|
||||
* Background: B4 introduced the `parked` state in
|
||||
* `src/lib/integrations/status.ts`. Once a Withings persistent-failure
|
||||
* streak (contract-mismatch 293/294) has been running for more than
|
||||
* 24h, the status writer flips the row to `parked` and the sync
|
||||
* entry-point short-circuits via `isReauthRequired`. The user (or an
|
||||
* operator) calls this endpoint to clear the park and let the next
|
||||
* cron tick attempt the sync again.
|
||||
*
|
||||
* Rate-limited (5/min per user) to match the existing
|
||||
* `/api/integrations/withings/test` endpoint — there's no upstream
|
||||
* call here, but a misbehaving client tab loop would still rack up
|
||||
* audit-log rows if the limit was absent.
|
||||
*
|
||||
* Returns 200 with `{ resumed: true, wasParked: boolean }`. The
|
||||
* `wasParked` flag lets the caller decide whether to surface a "you
|
||||
* resumed it" toast or treat the action as a no-op (the row was
|
||||
* already connected / disconnected). Either way the endpoint is
|
||||
* idempotent — a second click in the same second sees the same
|
||||
* post-state and writes no extra audit row.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resumeIntegrationFromPark } from "@/lib/integrations/status";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
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,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many resume requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await resumeIntegrationFromPark(user.id, "withings");
|
||||
annotate({
|
||||
meta: {
|
||||
withings_resume_was_parked: result.wasParked,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ resumed: true, wasParked: result.wasParked });
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on PUT /api/measurements/[id].
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function putReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/measurements/m1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// `value=-1` (below min 0) + `measuredAt` not a valid ISO.
|
||||
const res = await PUT(
|
||||
putReq({ value: -1, measuredAt: "not-iso" }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// `value` not a number + `measuredAt` not iso + `notes` too long.
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
value: "string",
|
||||
measuredAt: "not-iso",
|
||||
notes: "a".repeat(200),
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; code: string }> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes a measurements.update.validation-failed audit row", async () => {
|
||||
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);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("measurements.update.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await PUT(
|
||||
putReq({ value: -1, measuredAt: "junk" }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { updateMeasurementSchema } from "@/lib/validations/measurement";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
@@ -61,7 +63,25 @@ export const PUT = apiHandler(
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = updateMeasurementSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — measurement edit hot path; multi-issue 422 +
|
||||
// audit breadcrumb keyed `measurements.update.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "measurements.update.validation-failed" },
|
||||
meta: { issue_count: issues.length, measurement_id: id },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurements.update.validation-failed",
|
||||
details: JSON.stringify({ issues, measurementId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
|
||||
@@ -27,6 +27,8 @@ vi.mock("@/lib/db", () => ({
|
||||
measurementRollup: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
// v1.4.43 W6 — validation-failed paths write an audit breadcrumb.
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -69,6 +71,10 @@ function getRequest(query: string): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// v1.4.43 W6 — validation-failed paths fire a best-effort audit-row
|
||||
// write; the route swallows rejections so the test only needs a
|
||||
// resolved mock to keep the catch-block silent.
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on the measurements route.
|
||||
*
|
||||
* GET, batch POST and single POST now return every Zod issue under
|
||||
* `details.issues` and write an audit-ledger breadcrumb so the
|
||||
* operator can grep `/api/admin/audit` for the corresponding
|
||||
* `<route>.validation-failed` rows.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
measurementRollup: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
toJson: (v: unknown) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
function getReq(qs: string): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/measurements?${qs}`);
|
||||
}
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/measurements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("GET /api/measurements — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// `limit=999999` (above 5000 max) + `offset=-1` (below 0 min).
|
||||
const res = await GET(getReq("limit=999999&offset=-1"));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// bad sortBy + bad sortDir + bad aggregate.
|
||||
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 }> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes a measurements.list.validation-failed audit row", async () => {
|
||||
const res = await GET(getReq("limit=999999&offset=-1"));
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string; details: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("measurements.list.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await GET(getReq("limit=999999&offset=-1"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
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" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Bad enum (`type`) + bad number (`value`) + bad iso (`measuredAt`).
|
||||
const res = await POST(
|
||||
postReq({ type: "NOT_A_TYPE", value: "stringy", measuredAt: "x" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; code: string }> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes a measurements.create.validation-failed audit row", async () => {
|
||||
const res = await POST(postReq({ measuredAt: "junk" }));
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string; details: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("measurements.create.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await POST(postReq({ measuredAt: "junk" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/measurements — batch — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors across the batch", async () => {
|
||||
// Two malformed entries: one with bad type, one with bad measuredAt.
|
||||
const res = await POST(
|
||||
postReq([
|
||||
{ type: "NOT_A_TYPE", value: 100, measuredAt: "2026-01-01T00:00:00Z" },
|
||||
{ type: "WEIGHT", value: 70, measuredAt: "definitely-not-iso" },
|
||||
]),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; code: string }> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors across the batch", async () => {
|
||||
const res = await POST(
|
||||
postReq([
|
||||
{ type: "WEIGHT", value: "string", measuredAt: "2026-01-01T00:00:00Z" },
|
||||
{ type: "NOT_A_TYPE", value: 70, measuredAt: "2026-01-01T00:00:00Z" },
|
||||
{ type: "WEIGHT", value: 70, measuredAt: "nope" },
|
||||
]),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; code: string }> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes a measurements.create.batch.validation-failed audit row", async () => {
|
||||
const res = await POST(
|
||||
postReq([
|
||||
{ type: "WEIGHT", value: 70, measuredAt: "junk" },
|
||||
{ type: "NOT_A_TYPE", value: 70, measuredAt: "2026-01-01T00:00:00Z" },
|
||||
]),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string; details: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("measurements.create.batch.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await POST(
|
||||
postReq([
|
||||
{ type: "WEIGHT", value: 70, measuredAt: "junk" },
|
||||
{ type: "NOT_A_TYPE", value: 70, measuredAt: "2026-01-01T00:00:00Z" },
|
||||
]),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
createMeasurementSchema,
|
||||
@@ -45,7 +47,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const params = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listMeasurementsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — surface every Zod issue under `details.issues` so a
|
||||
// multi-field iOS query (chart loader bug, drill-down regression)
|
||||
// doesn't iterate one error per round-trip. Best-effort audit-
|
||||
// ledger breadcrumb keyed `measurements.list.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "measurements.list.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurements.list.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -553,7 +575,26 @@ async function postMeasurement(request: NextRequest) {
|
||||
measurements: body,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — iOS batch ingest (BP + Pulse, combined BG) needs
|
||||
// every issue echoed back so a one-shot retry can fix the full
|
||||
// payload rather than walk it field-by-field.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "measurements.create.batch.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurements.create.batch.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const results = await prisma.$transaction(
|
||||
@@ -622,7 +663,26 @@ async function postMeasurement(request: NextRequest) {
|
||||
// Single mode (existing behavior)
|
||||
const parsed = createMeasurementSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — single-row POST is the iOS fallback path; one
|
||||
// round-trip per stale-field bug was the v1.4.41 iOS-contract
|
||||
// pain point. Multi-issue envelope + audit breadcrumb.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "measurements.create.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurements.create.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { type, value, measuredAt, notes, source, glucoseContext, deviceType } =
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findMany: vi.fn() } },
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
@@ -38,6 +41,7 @@ function req(query: string): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/measurements/series", () => {
|
||||
@@ -103,3 +107,62 @@ describe("GET /api/measurements/series", () => {
|
||||
expect(body.data.points[0].secondary).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// bad `kind` (invalid enum) + `days=0` (below min 1).
|
||||
const res = await GET(req("kind=garbage&days=0"));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces multiple simultaneous validation errors (≥2)", async () => {
|
||||
// The series schema has only two knobs (kind + days), so a strict
|
||||
// 3-issue case is not natural. We pin the multi-issue contract on
|
||||
// ≥ 2 here — the helper's 3-issue path is exhaustively covered by
|
||||
// `src/lib/__tests__/api-response-zod.test.ts` and the routes with
|
||||
// wider schemas (measurements, devices, mood-entries).
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(req("kind=junk&days=-999"));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string }> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("writes a measurements.series.validation-failed audit row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(req("kind=garbage&days=0"));
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("measurements.series.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await GET(req("kind=garbage&days=0"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,11 @@ import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
@@ -70,7 +74,25 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — iOS chart loader hot path; multi-issue 422 +
|
||||
// audit breadcrumb keyed `measurements.series.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "measurements.series.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurements.series.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { kind, days } = parsed.data;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on PUT /api/medications/[id].
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/medication-category", () => ({
|
||||
deleteMedicationCategory: vi.fn().mockResolvedValue(undefined),
|
||||
getMedicationCategories: vi.fn().mockResolvedValue({}),
|
||||
setMedicationCategory: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function putReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/m1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad `name` (number) + bad `active` (string).
|
||||
const res = await PUT(putReq({ name: 123, active: "string" }), ROUTE_CTX);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Bad name + bad active + bad category.
|
||||
const res = await PUT(
|
||||
putReq({ name: 123, active: "string", category: 999 }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -166,3 +166,39 @@ describe("GET /api/medications/[id]/cadence", () => {
|
||||
expect(json.data.next).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/medications/[id]/cadence — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
} as never);
|
||||
// Schema accepts `days` (coerce.number, range 1-365). Force two
|
||||
// failures: non-numeric `days` + extra unknown knob is silently
|
||||
// stripped, so we use the bounds-violation route: a negative number
|
||||
// string still coerces to a negative int → min violation.
|
||||
// For ≥2 we use a non-coercible string AND query for too-many keys.
|
||||
// Cadence schema is small, so we pin the contract on ≥ 2 issues.
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/med-1/cadence?days=notanumber",
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(1);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,11 @@ 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 } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
buildCadenceTimeline,
|
||||
computeNextDose,
|
||||
@@ -57,7 +61,8 @@ export const GET = apiHandler(
|
||||
days: url.searchParams.get("days") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
const windowDays = parsed.data.days ?? 30;
|
||||
const asOf = new Date();
|
||||
|
||||
@@ -335,3 +335,55 @@ describe("POST /api/medications/[id]/glp1", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/glp1 — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "not-iso",
|
||||
doseValue: "string",
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "not-iso",
|
||||
doseValue: "string",
|
||||
doseUnit: 999,
|
||||
note: 123,
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -145,7 +146,8 @@ export const POST = apiHandler(
|
||||
|
||||
const parsed = glp1PostBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const ip = getClientIp(request);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on PUT
|
||||
* /api/medications/[id]/intake/[eventId].
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medicationIntakeEvent: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function putReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/m1/intake/e1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const ROUTE_CTX = {
|
||||
params: Promise.resolve({ id: "m1", eventId: "e1" }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: new Date(),
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id]/intake/[eventId] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad `takenAt` iso + bad `skipped` (not boolean).
|
||||
const res = await PUT(
|
||||
putReq({ takenAt: "not-iso", skipped: "string" }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Bad takenAt + bad skipped + bad scheduledFor.
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
takenAt: "not-iso",
|
||||
skipped: "string",
|
||||
scheduledFor: "also-not-iso",
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes the audit-ledger row keyed medications.intake.event.update.validation-failed", async () => {
|
||||
const res = await PUT(
|
||||
putReq({ takenAt: "not-iso", skipped: "string" }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.action).toBe(
|
||||
"medications.intake.event.update.validation-failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await PUT(
|
||||
putReq({ takenAt: "not-iso", skipped: "string" }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { updateIntakeEventSchema } from "@/lib/validations/medication";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
@@ -34,7 +36,34 @@ export const PUT = apiHandler(
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = updateIntakeEventSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — per-event update hot path; multi-issue 422 +
|
||||
// audit breadcrumb keyed
|
||||
// `medications.intake.event.update.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "medications.intake.event.update.validation-failed" },
|
||||
meta: {
|
||||
issue_count: issues.length,
|
||||
medication_id: id,
|
||||
event_id: eventId,
|
||||
},
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.event.update.validation-failed",
|
||||
details: JSON.stringify({
|
||||
issues,
|
||||
medicationId: id,
|
||||
eventId,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
|
||||
@@ -27,11 +27,38 @@ vi.mock("@/lib/db", () => ({
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationIntakeEvent: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/inventory/service", () => ({
|
||||
consumeOneDose: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -53,7 +80,7 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { GET, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
@@ -80,6 +107,8 @@ beforeEach(() => {
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
|
||||
// v1.4.43 W6 — default the audit-row write to resolve.
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/medications/[id]/intake — status filter", () => {
|
||||
@@ -155,3 +184,85 @@ describe("GET /api/medications/[id]/intake — status filter", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/med-1/intake", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it("GET surfaces TWO simultaneous validation errors", async () => {
|
||||
const res = await GET(makeRequest("status=junk&sortBy=garbage"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST surfaces TWO simultaneous validation errors", async () => {
|
||||
// Schema requires takenAt iso + skipped boolean.
|
||||
const res = await POST(
|
||||
postReq({ takenAt: "not-iso", skipped: "string" }),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST surfaces THREE simultaneous validation errors", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
takenAt: "not-iso",
|
||||
skipped: "string",
|
||||
scheduledFor: "also-not-iso",
|
||||
}),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes audit-ledger rows for both GET and POST validation failures", async () => {
|
||||
await GET(makeRequest("status=junk"), ROUTE_PARAMS);
|
||||
await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalled();
|
||||
const actions = vi
|
||||
.mocked(prisma.auditLog.create)
|
||||
.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");
|
||||
});
|
||||
|
||||
it("POST does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST
|
||||
* /api/medications/[id]/intake/import. Preserves the
|
||||
* `medication.intake.import.invalid_format` errorCode meta so the CSV
|
||||
* import client UI can branch on the prefix semantics.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
|
||||
dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/m1/intake/import", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Two entries with malformed datum + malformed uhrzeit.
|
||||
const res = await POST(
|
||||
postReq([
|
||||
{ datum: "not-a-date", uhrzeit: "07:00:00" },
|
||||
{ datum: "2026-01-01", uhrzeit: "not-a-time" },
|
||||
]),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
// Special: the CSV-import UI branches on this errorCode to preserve
|
||||
// the historical "Invalid format: …" client behaviour.
|
||||
expect(body.meta?.errorCode).toBe(
|
||||
"medication.intake.import.invalid_format",
|
||||
);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
const res = await POST(
|
||||
postReq([
|
||||
{ datum: "not-a-date", uhrzeit: "not-a-time" },
|
||||
{ datum: "also-bad", uhrzeit: "07:00:00" },
|
||||
{ datum: "2026-01-01", uhrzeit: "weird" },
|
||||
]),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
expect(body.meta?.errorCode).toBe(
|
||||
"medication.intake.import.invalid_format",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes the audit-ledger row keyed medications.intake.import.validation-failed", async () => {
|
||||
const res = await POST(
|
||||
postReq([{ datum: "not-a-date", uhrzeit: "07:00:00" }]),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.action).toBe(
|
||||
"medications.intake.import.validation-failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await POST(
|
||||
postReq([{ datum: "not-a-date", uhrzeit: "07:00:00" }]),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
recomputeMedicationComplianceForDay,
|
||||
@@ -48,7 +50,32 @@ export const POST = apiHandler(
|
||||
|
||||
const parsed = importSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return apiError(`Invalid format: ${parsed.error.issues[0].message}`, 422);
|
||||
// v1.4.43 W6 — CSV import; preserve the `Invalid format:` prefix
|
||||
// semantics for the existing client-side branch by setting the
|
||||
// `errorCode: "medication.intake.import.invalid_format"` meta.
|
||||
// The client UI can now branch on `meta.errorCode` while every
|
||||
// Zod issue is also surfaced under `details.issues`. Bulk-import
|
||||
// path → audit breadcrumb keyed
|
||||
// `medications.intake.import.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "medications.intake.import.validation-failed" },
|
||||
meta: { issue_count: issues.length, medication_id: id },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.import.validation-failed",
|
||||
details: JSON.stringify({ issues, medicationId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "medication.intake.import.invalid_format",
|
||||
});
|
||||
}
|
||||
|
||||
const entries = parsed.data;
|
||||
|
||||
@@ -4,9 +4,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
intakeSchema,
|
||||
@@ -41,7 +42,25 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
medicationId: id,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — per-med intake POST hot path; multi-issue 422 +
|
||||
// audit breadcrumb keyed `medications.intake.create.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "medications.intake.create.validation-failed" },
|
||||
meta: { issue_count: issues.length, medication_id: id },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.create.validation-failed",
|
||||
details: JSON.stringify({ issues, medicationId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { scheduledFor, takenAt, skipped, idempotencyKey } = parsed.data;
|
||||
@@ -185,7 +204,25 @@ export const GET = apiHandler(
|
||||
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listIntakeEventsSchema.safeParse(searchParams);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422 + audit breadcrumb keyed
|
||||
// `medications.intake.list.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "medications.intake.list.validation-failed" },
|
||||
meta: { issue_count: issues.length, medication_id: id },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.list.validation-failed",
|
||||
details: JSON.stringify({ issues, medicationId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { limit, offset, sortBy, sortDir, status } = parsed.data;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on PATCH
|
||||
* /api/medications/[id]/inventory/[itemId].
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationInventoryItem: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/medications/inventory/service", () => ({
|
||||
computeExpiresAt: vi.fn().mockReturnValue(null),
|
||||
buildPatchInventoryUpdate: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function patchReq(body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/medications/m1/inventory/i1",
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const ROUTE_CTX = {
|
||||
params: Promise.resolve({ id: "m1", itemId: "i1" }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
|
||||
id: "i1",
|
||||
medicationId: "m1",
|
||||
userId: "user-1",
|
||||
firstUseAt: null,
|
||||
state: "ACTIVE",
|
||||
dosesRemaining: 4,
|
||||
printedExpiry: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("PATCH /api/medications/[id]/inventory/[itemId] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad `markAsFirstUseAt` iso + bad `markAsUsedUp` (not boolean).
|
||||
const res = await PATCH(
|
||||
patchReq({ markAsFirstUseAt: "not-iso", markAsUsedUp: "string" }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
const res = await PATCH(
|
||||
patchReq({
|
||||
markAsFirstUseAt: "not-iso",
|
||||
markAsUsedUp: "string",
|
||||
printedExpiry: "also-bad",
|
||||
notes: 123,
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { updateInventoryItemSchema } from "@/lib/validations/medication";
|
||||
@@ -60,7 +61,8 @@ export const PATCH = apiHandler(
|
||||
|
||||
const parsed = updateInventoryItemSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { markAsFirstUseAt, markAsUsedUp, printedExpiry, notes } =
|
||||
|
||||
@@ -349,3 +349,50 @@ describe("DELETE /api/medications/[id]/inventory/[itemId]", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: "string",
|
||||
printedExpiry: "not-iso",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: "string",
|
||||
printedExpiry: "not-iso",
|
||||
purchasedAt: "also-not-iso",
|
||||
notes: 123,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -83,7 +84,8 @@ export const POST = apiHandler(
|
||||
|
||||
const parsed = createInventoryItemSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { dosesTotal, printedExpiry, purchasedAt, notes } = parsed.data;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { updateMedicationSchema } from "@/lib/validations/medication";
|
||||
@@ -69,7 +70,8 @@ export const PUT = apiHandler(
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = updateMedicationSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const {
|
||||
|
||||
@@ -350,3 +350,70 @@ describe("DELETE /api/medications/[id]/side-effects/[logId]", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/medications/[id]/side-effects — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("GET surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
// Bad `from` iso + bad `limit` (out of bounds).
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/med-1/side-effects?from=not-iso&limit=99999",
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
// Missing `entry` + bad `severity`.
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/side-effects", {
|
||||
severity: "junk",
|
||||
notes: "x".repeat(2000),
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("POST surfaces THREE simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/side-effects", {
|
||||
entry: 999,
|
||||
severity: "junk",
|
||||
occurredAt: "not-iso",
|
||||
notes: "x".repeat(5000),
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -56,7 +57,8 @@ export const GET = apiHandler(
|
||||
limit: url.searchParams.get("limit") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { from, to, limit } = parsed.data;
|
||||
@@ -114,7 +116,8 @@ export const POST = apiHandler(
|
||||
|
||||
const parsed = createSideEffectSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { entry, severity, occurredAt, notes } = parsed.data;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/medications.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn(),
|
||||
},
|
||||
medicationCategoryAssignment: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/cache/server-cache", () => ({
|
||||
cached: vi.fn(async (_c: unknown, _k: string, f: () => Promise<unknown>) =>
|
||||
f(),
|
||||
),
|
||||
caches: { medicationsList: {} },
|
||||
__resetAllCachesForTests: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/medication-category", () => ({
|
||||
getMedicationCategories: vi.fn().mockResolvedValue({}),
|
||||
setMedicationCategory: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/timezone", () => ({
|
||||
getUserTodayBounds: vi
|
||||
.fn()
|
||||
.mockReturnValue({ start: new Date(), end: new Date() }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
describe("POST /api/medications — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Missing `name` + bad `dose` (e.g. number).
|
||||
const res = await POST(postReq({ dose: 42, dosesPerUnit: -1 }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// name not a string + bad dose + bad dosesPerUnit.
|
||||
const res = await POST(
|
||||
postReq({ name: 123, dose: 42, dosesPerUnit: "bad" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@ vi.mock("@/lib/db", () => ({
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
// v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths.
|
||||
auditLog: { create: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
// v1.4.39 QA F-H-01 — coverage probe + atomic upsert use raw SQL.
|
||||
$queryRaw: vi.fn(),
|
||||
@@ -97,6 +99,8 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{ rolled_days: BigInt(0), event_days: BigInt(0) },
|
||||
] as never);
|
||||
// v1.4.43 W6 — default the audit-row write to resolve.
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/medications/intake", () => {
|
||||
@@ -396,3 +400,89 @@ describe("v1.4.39 W-MED — compliance rollup read swap", () => {
|
||||
expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/intake", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it("GET surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Bad `scope` + bad `days` (NaN-coercible string).
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/intake?scope=junk&days=notanumber",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Missing `intakeId` (min 1) + bad `status` enum.
|
||||
const res = await POST(postReq({ status: "broken" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST surfaces THREE simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Bad intakeId (empty) + bad status + bad takenAt iso.
|
||||
const res = await POST(
|
||||
postReq({ intakeId: "", status: "broken", takenAt: "not-iso" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes the audit-ledger row keyed medications.intake.update.validation-failed", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(postReq({ status: "broken" }));
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.action).toBe(
|
||||
"medications.intake.update.validation-failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await POST(postReq({ status: "broken" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/medications/intake/bulk.
|
||||
* Preserves the `medications.intake.bulk.invalid` errorCode meta passthrough.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
|
||||
dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/intake/bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
});
|
||||
|
||||
describe("POST /api/medications/intake/bulk — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ medicationId: "", scheduledFor: "2026-01-01T00:00:00Z" },
|
||||
{ medicationId: "m1", scheduledFor: "not-iso" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
expect(body.meta?.errorCode).toBe("medications.intake.bulk.invalid");
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ medicationId: "", scheduledFor: "2026-01-01T00:00:00Z" },
|
||||
{ medicationId: "m2", scheduledFor: "not-iso" },
|
||||
{ medicationId: "m3", takenAt: "also-not-iso" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
expect(body.meta?.errorCode).toBe("medications.intake.bulk.invalid");
|
||||
});
|
||||
|
||||
it("writes the audit-ledger row keyed medications.intake.bulk.validation-failed", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [{ medicationId: "", scheduledFor: "not-iso" }],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.action).toBe("medications.intake.bulk.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [{ medicationId: "", scheduledFor: "not-iso" }],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -109,7 +111,27 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
|
||||
const parsed = bulkPayloadSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422, {
|
||||
// v1.4.43 W6 — bulk intake ingest; preserve the existing
|
||||
// `medications.intake.bulk.invalid` errorCode meta so the iOS
|
||||
// Sync engine's retry classifier still branches on it. Audit
|
||||
// breadcrumb keyed `medications.intake.bulk.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "medications.intake.bulk.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.bulk.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "medications.intake.bulk.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -73,7 +75,25 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422 + audit breadcrumb keyed
|
||||
// `medications.intake.list.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "medications.intake.list.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.list.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { scope, days } = parsed.data;
|
||||
@@ -261,7 +281,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — intake-event update hot path; multi-issue 422 +
|
||||
// audit breadcrumb keyed `medications.intake.update.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "medications.intake.update.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.update.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { intakeId, status, takenAt, snoozedUntil } = parsed.data;
|
||||
|
||||
@@ -4,8 +4,8 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { createMedicationSchema } from "@/lib/validations/medication";
|
||||
@@ -110,7 +110,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = createMedicationSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { name, dose, category, treatmentClass, dosesPerUnit, schedules } =
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on PUT /api/mood-entries/[id].
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodEntry: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMood: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function putReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/mood-entries/me1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const ROUTE_CTX = { params: Promise.resolve({ id: "me1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({
|
||||
id: "me1",
|
||||
userId: "user-1",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad `mood` enum + bad `moodLoggedAt`.
|
||||
const res = await PUT(
|
||||
putReq({ mood: "junk", moodLoggedAt: "not-iso" }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Bad mood + bad moodLoggedAt + bad note (too long).
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
mood: "junk",
|
||||
moodLoggedAt: "not-iso",
|
||||
note: "x".repeat(2000),
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes a mood-entries.update.validation-failed audit row", async () => {
|
||||
const res = await PUT(
|
||||
putReq({ mood: "junk", moodLoggedAt: "not-iso" }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.action).toBe("mood-entries.update.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await PUT(
|
||||
putReq({ mood: "junk", moodLoggedAt: "not-iso" }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
updateMoodEntrySchema,
|
||||
@@ -66,7 +68,25 @@ export const PUT = apiHandler(
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = updateMoodEntrySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — mood edit hot path; multi-issue 422 + audit
|
||||
// breadcrumb keyed `mood-entries.update.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "mood-entries.update.validation-failed" },
|
||||
meta: { issue_count: issues.length, moodEntryId: id },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "mood-entries.update.validation-failed",
|
||||
details: JSON.stringify({ issues, moodEntryId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on GET + POST /api/mood-entries.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodEntry: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMood: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function getReq(qs: string): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/mood-entries?${qs}`);
|
||||
}
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/mood-entries", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad `mood` enum + bad `sortBy` enum.
|
||||
const res = await GET(getReq("mood=junk&sortBy=garbage"));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Bad mood + bad sortBy + bad sortDir.
|
||||
const res = await GET(
|
||||
getReq("mood=junk&sortBy=garbage&sortDir=upside"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; code: string }> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes a mood-entries.list.validation-failed audit row", async () => {
|
||||
const res = await GET(getReq("mood=junk&sortBy=garbage"));
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.action).toBe("mood-entries.list.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await GET(getReq("mood=junk&sortBy=garbage"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad `mood` enum + missing `moodLoggedAt`.
|
||||
const res = await POST(postReq({ mood: "ecstatic-junk" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Bad `mood` + bad `moodLoggedAt` + bad `note` (too long).
|
||||
const res = await POST(
|
||||
postReq({
|
||||
mood: "junk",
|
||||
moodLoggedAt: "definitely-not-iso",
|
||||
note: "x".repeat(2000),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("writes a mood-entries.create.validation-failed audit row", async () => {
|
||||
const res = await POST(postReq({ mood: "junk" }));
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.action).toBe("mood-entries.create.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await POST(postReq({ mood: "junk" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/mood-entries/bulk.
|
||||
* Preserves the `mood.bulk.invalid` errorCode meta passthrough.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodEntry: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMood: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/mood-entries/bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ mood: "junk", moodLoggedAt: "2026-01-01T00:00:00Z" },
|
||||
{ mood: "happy", moodLoggedAt: "not-iso" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
expect(body.meta?.errorCode).toBe("mood.bulk.invalid");
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors across entries", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ mood: "junk-1", moodLoggedAt: "2026-01-01T00:00:00Z" },
|
||||
{ mood: "junk-2", moodLoggedAt: "not-iso" },
|
||||
{ mood: "junk-3", moodLoggedAt: "also-not-iso" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
expect(body.meta?.errorCode).toBe("mood.bulk.invalid");
|
||||
});
|
||||
|
||||
it("writes a mood.bulk.validation-failed audit row", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ mood: "junk-1", moodLoggedAt: "2026-01-01T00:00:00Z" },
|
||||
{ mood: "junk-2", moodLoggedAt: "not-iso" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.action).toBe("mood.bulk.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ mood: "junk-1", moodLoggedAt: "2026-01-01T00:00:00Z" },
|
||||
{ mood: "junk-2", moodLoggedAt: "not-iso" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,9 @@ import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -111,7 +113,27 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
|
||||
const parsed = bulkPayloadSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422, {
|
||||
// v1.4.43 W6 — bulk mood ingest; keep the `mood.bulk.invalid`
|
||||
// errorCode meta intact so the iOS Sync engine's retry classifier
|
||||
// still branches on it. Adds the audit-ledger breadcrumb keyed
|
||||
// `mood.bulk.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "mood.bulk.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "mood.bulk.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "mood.bulk.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
createMoodEntrySchema,
|
||||
@@ -35,7 +37,25 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const params = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listMoodEntriesSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — surface every Zod issue + audit breadcrumb keyed
|
||||
// `mood-entries.list.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "mood-entries.list.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "mood-entries.list.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { mood, from, to, limit, offset, sortBy, sortDir } = parsed.data;
|
||||
@@ -89,7 +109,25 @@ async function postMoodEntry(request: NextRequest) {
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = createMoodEntrySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — iOS mood log hot path; multi-issue 422 + audit
|
||||
// breadcrumb keyed `mood-entries.create.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "mood-entries.create.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "mood-entries.create.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { mood, tags, note, moodLoggedAt, source } = parsed.data;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/tokens.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
apiToken: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/tokens", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("POST /api/tokens — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Empty name (min 1) + `expiresInDays` above max (365).
|
||||
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Missing name + `expiresInDays` not a number + `expiresInDays` below min
|
||||
// is hard since name and expiresInDays are the only knobs, so we use a
|
||||
// name that is too long (max 100) + expiresInDays as a string and as
|
||||
// a fraction (non-int).
|
||||
const res = await POST(
|
||||
postReq({
|
||||
name: "x".repeat(200),
|
||||
expiresInDays: "string",
|
||||
// any extra unknown key Zod will silently strip; force a 3rd issue
|
||||
// by including a stray nested shape that the schema doesn't accept.
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; code: string }> };
|
||||
};
|
||||
// name too long + expiresInDays not a number = 2 issues at least.
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("writes a tokens.create.validation-failed audit row", async () => {
|
||||
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("tokens.create.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { randomBytes } from "node:crypto";
|
||||
@@ -51,7 +57,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = createTokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422 + audit-ledger breadcrumb so an
|
||||
// operator can grep `tokens.create.validation-failed` when a
|
||||
// device sends a malformed mint payload.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "tokens.create.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "tokens.create.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { name, expiresInDays } = parsed.data;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on PUT /api/user/thresholds.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
// The thresholds validator imports METRIC_BOUNDS at module top-level
|
||||
// to build its strict-object schema, so we need to keep the real export
|
||||
// surface around when we mock the module.
|
||||
vi.mock("@/lib/analytics/effective-range", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/analytics/effective-range")>();
|
||||
return {
|
||||
...actual,
|
||||
getAllEffectiveRanges: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function putReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/user/thresholds", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/user/thresholds — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// WEIGHT bounds are min=30, max=300. Sending min > max + a value
|
||||
// out of bounds on PULSE forces two range errors.
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
WEIGHT: { min: 100, max: 50 }, // refine min < max
|
||||
PULSE: { min: 1000, max: 2000 }, // out of bounds
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
WEIGHT: { min: 100, max: 50 },
|
||||
PULSE: { min: 1000, max: 2000 },
|
||||
BODY_FAT: { min: 999, max: 1000 },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
getClientIp,
|
||||
} from "@/lib/api-response";
|
||||
@@ -80,7 +81,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = thresholdsUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
|
||||
+17
-12
@@ -3,9 +3,7 @@
|
||||
/**
|
||||
* Root-level error boundary. Renders when even the root layout itself fails,
|
||||
* so it cannot rely on any providers (i18n, query, auth). Keep it static,
|
||||
* self-contained, and bilingual: both English and German appear side by side
|
||||
* so a German visitor never lands on an all-English last-resort screen, and
|
||||
* an English visitor can still read it.
|
||||
* in English, and self-contained.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
@@ -30,7 +28,7 @@ export default function GlobalError({
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
window.prompt("Details kopieren · Copy details", text);
|
||||
window.prompt("Copy details", text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,14 +54,21 @@ export default function GlobalError({
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
{/*
|
||||
v1.4.43 QoL — bilingual lockup so a German user landing
|
||||
in this boundary doesn't read pure English in an already-
|
||||
bad-state moment. The root layout has failed by the time
|
||||
this fires, so no i18n provider is available; the static
|
||||
DE/EN pairing covers the dominant audience without a
|
||||
runtime cost. Copy tightened per the v1.4.43 audit (L1)
|
||||
to drop the verbose secondary sentence.
|
||||
*/}
|
||||
<h1 style={{ fontSize: 20, marginBottom: 12 }}>
|
||||
Etwas ist schiefgegangen · Something went wrong
|
||||
Etwas ist schiefgegangen / Something went wrong
|
||||
</h1>
|
||||
<p style={{ color: "#d0d0d0", marginBottom: 16, fontSize: 14 }}>
|
||||
Ein kritischer Fehler ist aufgetreten. Du kannst es erneut versuchen
|
||||
oder die Details kopieren.
|
||||
<br />A critical error occurred. You can retry or copy the details
|
||||
for support.
|
||||
Ein kritischer Fehler ist aufgetreten. / A critical error
|
||||
occurred.
|
||||
</p>
|
||||
<pre
|
||||
style={{
|
||||
@@ -76,7 +81,7 @@ export default function GlobalError({
|
||||
border: "1px solid #44475a",
|
||||
}}
|
||||
>
|
||||
{error.message || "Unbekannter Fehler · Unknown error"}
|
||||
{error.message || "Unknown error"}
|
||||
</pre>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button
|
||||
@@ -96,7 +101,7 @@ export default function GlobalError({
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Erneut versuchen · Retry
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
@@ -111,7 +116,7 @@ export default function GlobalError({
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Details kopieren · Copy details
|
||||
Copy details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* v1.4.43 W11 — insights mother-page dynamic-skeleton guards.
|
||||
*
|
||||
* The three `next/dynamic` loading placeholders for
|
||||
* `DailyBriefing` / `CorrelationRow` / `TrendsRow` used to reserve
|
||||
* heights (`h-48` / `h-32` / `h-64`) noticeably shorter than the
|
||||
* loaded content (~24 rem / ~20 rem each), which CLS-shifted the
|
||||
* page on slow networks. They also lacked `motion-reduce:animate-none`,
|
||||
* so motion-sensitive users saw a continuous pulse.
|
||||
*
|
||||
* This textual guard pins both the larger reserved heights and the
|
||||
* motion-reduce class. The check is intentionally simple — render-
|
||||
* mounting the page would haul in TanStack-Query / Auth / I18n
|
||||
* scaffolding for a property a substring search already proves.
|
||||
*/
|
||||
describe("insights mother-page dynamic-skeleton heights + motion-reduce", () => {
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/app/insights/page.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("DailyBriefing skeleton reserves h-[24rem]", () => {
|
||||
expect(src).toMatch(
|
||||
/DailyBriefing[\s\S]*?h-\[24rem\][^"]*motion-reduce:animate-none/,
|
||||
);
|
||||
});
|
||||
|
||||
it("CorrelationRow skeleton reserves h-[20rem]", () => {
|
||||
expect(src).toMatch(
|
||||
/CorrelationRow[\s\S]*?h-\[20rem\][^"]*motion-reduce:animate-none/,
|
||||
);
|
||||
});
|
||||
|
||||
it("TrendsRow skeleton reserves h-[20rem]", () => {
|
||||
expect(src).toMatch(
|
||||
/TrendsRow[\s\S]*?h-\[20rem\][^"]*motion-reduce:animate-none/,
|
||||
);
|
||||
});
|
||||
|
||||
it("no insights-page skeleton falls back to the legacy h-48/h-32/h-64 trio", () => {
|
||||
// The legacy classes shouldn't be reintroduced on the mother
|
||||
// page's three loading placeholders.
|
||||
const dynamicBlock = src.match(/const DailyBriefing[\s\S]*?const TrendsRow[\s\S]*?\);\n/);
|
||||
expect(dynamicBlock).not.toBeNull();
|
||||
const block = dynamicBlock?.[0] ?? "";
|
||||
expect(block).not.toMatch(/"[^"]*\bh-48\b[^"]*"/);
|
||||
expect(block).not.toMatch(/"[^"]*\bh-32\b[^"]*"/);
|
||||
expect(block).not.toMatch(/"[^"]*\bh-64\b[^"]*"/);
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ const DailyBriefing = dynamic(
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-card border-border h-48 animate-pulse rounded-xl border" />
|
||||
<div className="bg-card border-border h-[24rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
|
||||
),
|
||||
},
|
||||
);
|
||||
@@ -52,7 +52,7 @@ const CorrelationRow = dynamic(
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-card border-border h-32 animate-pulse rounded-xl border" />
|
||||
<div className="bg-card border-border h-[20rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
|
||||
),
|
||||
},
|
||||
);
|
||||
@@ -64,7 +64,7 @@ const TrendsRow = dynamic(
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-card border-border h-64 animate-pulse rounded-xl border" />
|
||||
<div className="bg-card border-border h-[20rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
+11
-9
@@ -13,14 +13,19 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
* application error and doesn't need the bug-report path. Just a
|
||||
* branded splash with one link home.
|
||||
*
|
||||
* v1.4.43 H4 — the copy is locale-resolved server-side from the same
|
||||
* cookie / Accept-Language path the rest of the app uses, so a German
|
||||
* visitor no longer hits an English-only splash on a typo URL.
|
||||
*
|
||||
* Mobile-first: `min-h-dvh` follows the dynamic viewport so the
|
||||
* panel anchors centred under iOS Safari's animated URL bar;
|
||||
* `safe-area-inset-top` keeps the headline clear of the notch or
|
||||
* Dynamic Island.
|
||||
*
|
||||
* v1.4.43 QoL — copy lifted into i18n keys (`notFound.title` /
|
||||
* `notFound.backToDashboard`) and tightened. The previous
|
||||
* marketing-flavoured paragraph ("The page you were looking for…
|
||||
* Head back to the dashboard to pick up where you left off.") read
|
||||
* heavier than the surface warrants; the single-line statement
|
||||
* matches the existing voice of every other in-app error message.
|
||||
* Translations cover all six locales so a German user no longer
|
||||
* lands on English copy.
|
||||
*/
|
||||
export default async function NotFound() {
|
||||
const locale = await resolveServerLocale();
|
||||
@@ -39,17 +44,14 @@ export default async function NotFound() {
|
||||
404
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
|
||||
{t("errors.notFound.title")}
|
||||
{t("notFound.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("errors.notFound.body")}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex min-h-11 items-center justify-center rounded-md px-5 text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("errors.notFound.backToDashboard")}
|
||||
{t("notFound.backToDashboard")}
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
|
||||
+54
-1
@@ -1303,6 +1303,27 @@ export default function DashboardPage() {
|
||||
|
||||
charts.sort((a, b) => a.order - b.order);
|
||||
|
||||
// v1.4.43 W11-M5 — tile-strip skeleton during slow slim-analytics
|
||||
// fetches.
|
||||
//
|
||||
// `trendCards` only fills once the slim `/api/analytics` slice
|
||||
// resolves and reports per-type `count > 0` flags. The v1.4.39.2
|
||||
// slim/thick split keeps the strip painting fast when slim wins,
|
||||
// but when *both* slices lag (cache eviction, cold start) the
|
||||
// user used to see the page header + 0 tiles + then 7 tiles
|
||||
// appear at once 9 s later. The audit recommendation is to
|
||||
// render a layout-stable tile silhouette keyed off the user's
|
||||
// configured tile count so the strip's footprint is reserved
|
||||
// during the slow window. The skeleton swaps in for the real
|
||||
// strip the moment `analyticsSlimQuery.isLoading` flips false.
|
||||
const configuredTileCount = layout.widgets.filter(
|
||||
(w) => w.tileVisible ?? w.visible,
|
||||
).length;
|
||||
const showTileStripSkeleton =
|
||||
trendCards.length === 0 &&
|
||||
analyticsSlimQuery.isLoading &&
|
||||
configuredTileCount > 0;
|
||||
|
||||
// v1.4.15 phase-C5: dashboard fully-empty state. When no tile
|
||||
// and no chart has data the dashboard would otherwise paint a
|
||||
// 0-px tile strip with the welcome banner above it — visually
|
||||
@@ -1313,7 +1334,15 @@ export default function DashboardPage() {
|
||||
// surface for very-new accounts; this empty state covers the
|
||||
// case where the checklist has been dismissed but no data was
|
||||
// logged afterwards.
|
||||
if (trendCards.length === 0 && charts.length === 0) {
|
||||
//
|
||||
// v1.4.43 W11 — the empty-state only fires once the slim slice
|
||||
// resolves with no tiles to show. While slim is in flight the
|
||||
// skeleton strip below carries the layout footprint.
|
||||
if (
|
||||
trendCards.length === 0 &&
|
||||
charts.length === 0 &&
|
||||
!showTileStripSkeleton
|
||||
) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
@@ -1357,6 +1386,30 @@ export default function DashboardPage() {
|
||||
CSS-grid `auto-fit + minmax + auto-rows-fr` track that
|
||||
continues to give every visible tile equal width / equal
|
||||
height for any non-zero count. */}
|
||||
{showTileStripSkeleton && (
|
||||
<div
|
||||
// v1.4.43 W11-M5 — tile-strip skeleton mirrors the real
|
||||
// grid track (`auto-fit minmax(min(100%,11rem),1fr)`) so
|
||||
// the layout footprint is reserved while slim-analytics
|
||||
// is in flight. Cards are keyed off the user's
|
||||
// configured tile count (`configuredTileCount`) — if the
|
||||
// user trimmed the strip to 3 tiles in Settings, the
|
||||
// skeleton shows 3 silhouettes, not 7.
|
||||
aria-hidden="true"
|
||||
data-slot="dashboard-tile-strip-skeleton"
|
||||
className={cn(
|
||||
"grid auto-rows-fr gap-3",
|
||||
"[grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]",
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: configuredTileCount }).map((_, idx) => (
|
||||
<div
|
||||
key={`tile-skeleton-${idx}`}
|
||||
className="bg-card border-border min-h-[6rem] animate-pulse rounded-xl border p-4 motion-reduce:animate-none md:p-6"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{trendCards.length > 0 && (
|
||||
<div
|
||||
// v1.4.33 A3 Win 2 + F3 — collapse the bifurcated
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* v1.4.43 W11 — motion-reduce sweep guard.
|
||||
*
|
||||
* Every `animate-spin` site in component / app code must also carry
|
||||
* `motion-reduce:animate-none` so that motion-sensitive users see a
|
||||
* static icon instead of a continuous rotation. The audit found 21
|
||||
* sites missing the modifier; this test prevents regressions.
|
||||
*
|
||||
* Scope: `src/**` excluding test files. The check is textual to avoid
|
||||
* rendering every loading state — a CI-cheap guard that catches drift
|
||||
* any time a new spinner is added without the modifier.
|
||||
*/
|
||||
const ROOT = join(process.cwd(), "src");
|
||||
const EXCLUDE_DIRS = new Set(["__tests__", "node_modules"]);
|
||||
|
||||
function collectTsxFiles(dir: string, acc: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (EXCLUDE_DIRS.has(entry)) continue;
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
collectTsxFiles(full, acc);
|
||||
} else if (
|
||||
entry.endsWith(".tsx") &&
|
||||
!entry.endsWith(".test.tsx") &&
|
||||
!entry.endsWith(".test.ts")
|
||||
) {
|
||||
acc.push(full);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
describe("motion-reduce coverage — every animate-spin pairs with motion-reduce:animate-none", () => {
|
||||
const files = collectTsxFiles(ROOT);
|
||||
|
||||
it("collects component files to scan", () => {
|
||||
expect(files.length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("every animate-spin site declares motion-reduce:animate-none", () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of files) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
const lines = src.split("\n");
|
||||
lines.forEach((line, idx) => {
|
||||
if (!line.includes("animate-spin")) return;
|
||||
// Skip comments and string literals that aren't classNames.
|
||||
if (line.trim().startsWith("//") || line.trim().startsWith("*"))
|
||||
return;
|
||||
if (!line.includes("motion-reduce:animate-none")) {
|
||||
violations.push(`${file}:${idx + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
expect(
|
||||
violations,
|
||||
`The following animate-spin sites are missing motion-reduce:animate-none. ` +
|
||||
`Append the modifier so motion-sensitive users see a static icon:\n` +
|
||||
violations.join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -166,7 +166,7 @@ export function ApiTokenOverviewSection() {
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : !tokens?.length ? (
|
||||
// v1.4.15 phase-C5: empty state explains where tokens come
|
||||
|
||||
@@ -362,7 +362,7 @@ export function BackupsSection() {
|
||||
className="min-h-11"
|
||||
>
|
||||
{runBackup.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<PlayCircle className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
@@ -418,7 +418,7 @@ export function BackupsSection() {
|
||||
className="min-h-11"
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
@@ -429,7 +429,7 @@ export function BackupsSection() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.section.backups.loading")}
|
||||
</span>
|
||||
@@ -459,7 +459,7 @@ export function BackupsSection() {
|
||||
className="min-h-11"
|
||||
>
|
||||
{runBackup.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<PlayCircle className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
@@ -518,7 +518,7 @@ export function BackupsSection() {
|
||||
className="min-h-11"
|
||||
>
|
||||
{downloadingId === row.id ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
|
||||
@@ -228,7 +228,7 @@ export function SystemStatusSection() {
|
||||
data-testid="system-status-retry"
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RotateCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
@@ -237,7 +237,7 @@ export function SystemStatusSection() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.loadingStatus")}
|
||||
</span>
|
||||
|
||||
@@ -50,4 +50,21 @@ describe("<ChartSkeleton>", () => {
|
||||
const html = render(<ChartSkeleton mini />);
|
||||
expect(html).toMatch(/h-\[140px\]/);
|
||||
});
|
||||
|
||||
// v1.4.43 QoL (L4) — slow-hint caption appears after a 3 s grace.
|
||||
// SSR markup never paints the caption because `useEffect` does not
|
||||
// run server-side; this also keeps hydration mismatch-free.
|
||||
it("does NOT paint the slow-hint caption in the initial SSR markup", () => {
|
||||
const html = render(<ChartSkeleton />);
|
||||
expect(html).not.toContain('data-slot="chart-skeleton-slow-hint"');
|
||||
expect(html).not.toContain("Computing analytics");
|
||||
});
|
||||
|
||||
it("mini variant never sets up the slow-hint timer", () => {
|
||||
// Read the component source: the `useEffect` early-returns when
|
||||
// `mini` is true, so a `<ChartSkeleton mini />` row in trends
|
||||
// never paints the multi-line caption that would overflow it.
|
||||
const html = render(<ChartSkeleton mini />);
|
||||
expect(html).not.toContain('data-slot="chart-skeleton-slow-hint"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.43 W11-M6 — chart empty-window state.
|
||||
*
|
||||
* Pre-fix the chart returned `null` when the loaded data array was
|
||||
* empty in the selected range, erasing the entire card (header +
|
||||
* tabs + chart). The user saw a missing widget without explanation.
|
||||
* The fix paints a "no data in this range" empty state via
|
||||
* `<ChartEmptyState>` so the dashboard composition stays intact and
|
||||
* the user knows the chart loaded but the selected window holds no
|
||||
* measurements. The copy is distinct from the < 3-points "Mehr
|
||||
* Messtage erforderlich" branch so the two situations don't blur.
|
||||
*/
|
||||
|
||||
function buildData(rawCount: number): unknown[] {
|
||||
const points: unknown[] = [];
|
||||
Object.defineProperty(points, "rawCount", {
|
||||
value: rawCount,
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
writable: false,
|
||||
});
|
||||
return points;
|
||||
}
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("<HealthChart> — empty-window state (W11-M6)", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("renders the no-data-in-range copy when data resolves to an empty array", async () => {
|
||||
const data = buildData(0);
|
||||
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data, isLoading: false }),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// The new empty state must paint…
|
||||
expect(html).toContain("Keine Daten in diesem Zeitraum");
|
||||
// …and the chart card chrome (title) must still be mounted so the
|
||||
// dashboard isn't missing a tile. The pre-fix `return null` would
|
||||
// have stripped this entirely.
|
||||
expect(html).toContain("Pulse");
|
||||
// The < 3-points branch must NOT paint here — empty-window and
|
||||
// sparse-data are different situations with distinct copy.
|
||||
expect(html).not.toContain("Mehr Messtage erforderlich");
|
||||
expect(html).not.toContain("Erfasse mindestens 3 Einträge");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
});
|
||||
@@ -98,8 +98,8 @@ export function ChartOverlayControls({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`text-muted-foreground hover:text-foreground min-h-11 min-w-11 px-0 ${triggerClassName ?? ""}`.trim()}
|
||||
size="default"
|
||||
className={`text-muted-foreground hover:text-foreground min-h-11 min-w-11 px-0 sm:min-h-9 sm:min-w-9 ${triggerClassName ?? ""}`.trim()}
|
||||
aria-label={t("chart.overlay.controls.tooltip.openSettings")}
|
||||
data-slot="chart-overlay-controls-trigger"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -31,10 +33,21 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* `rounded-md p-2` and pins the inner band at 140 px so the loading
|
||||
* shell occupies the same visible box as the painted chart.
|
||||
*
|
||||
* v1.4.43 QoL (L4) — when the skeleton is still painted three seconds
|
||||
* in, surface a small caption ("Computing analytics — this can take
|
||||
* a moment") so the user understands the chart is being computed
|
||||
* rather than broken. Today the `/api/analytics` correlations branch
|
||||
* can sit on the skeleton for ~9 s; the caption sets expectation.
|
||||
* Only fires on the full variant — the mini-skeleton sits in a
|
||||
* dashboard row strip where a multi-line caption would overflow.
|
||||
*
|
||||
* `prefers-reduced-motion` is honoured automatically — the `<Skeleton>`
|
||||
* primitive's `animate-pulse` is suppressed via Tailwind's
|
||||
* `motion-reduce:animate-none` modifier.
|
||||
*/
|
||||
|
||||
const SLOW_HINT_DELAY_MS = 3_000;
|
||||
|
||||
export function ChartSkeleton({
|
||||
className,
|
||||
mini = false,
|
||||
@@ -48,6 +61,21 @@ export function ChartSkeleton({
|
||||
mini?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
// v1.4.43 QoL (L4) — `showSlowHint` flips true 3 s into the load so
|
||||
// the caption only appears for genuinely slow renders. Using a
|
||||
// `useEffect` keeps the SSR markup identical (caption absent) so
|
||||
// hydration never mismatches; the timer kicks in client-side once
|
||||
// the skeleton actually paints.
|
||||
const [showSlowHint, setShowSlowHint] = useState(false);
|
||||
useEffect(() => {
|
||||
if (mini) return undefined;
|
||||
const handle = setTimeout(
|
||||
() => setShowSlowHint(true),
|
||||
SLOW_HINT_DELAY_MS,
|
||||
);
|
||||
return () => clearTimeout(handle);
|
||||
}, [mini]);
|
||||
|
||||
if (mini) {
|
||||
return (
|
||||
<div
|
||||
@@ -93,6 +121,15 @@ export function ChartSkeleton({
|
||||
{/* Chart band — mirrors the same `h-[var(--chart-height,…)]`
|
||||
contract `health-chart.tsx` paints at line 1087-1089. */}
|
||||
<Skeleton className="h-[var(--chart-height,240px)] w-full md:h-[var(--chart-height-md,280px)]" />
|
||||
|
||||
{showSlowHint && (
|
||||
<p
|
||||
data-slot="chart-skeleton-slow-hint"
|
||||
className="text-muted-foreground mt-3 text-xs"
|
||||
>
|
||||
{t("charts.loadingSlowHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1132,7 +1132,14 @@ export function HealthChart({
|
||||
return map;
|
||||
}, [chartData, types]);
|
||||
|
||||
if (!isLoading && !data?.length) return null;
|
||||
// v1.4.43 W11-M6 — keep the card shell mounted even when the loaded
|
||||
// payload is empty. Pre-fix the early `return null` here erased the
|
||||
// whole widget (header + range tabs + chart) and the user saw an
|
||||
// unexplained gap on the dashboard. The in-card render branch below
|
||||
// now paints a "no data in this range" empty state so the user
|
||||
// understands the chart loaded but the selected window holds no
|
||||
// measurements — distinct copy from the < 3-points "more days
|
||||
// needed" hint so the two situations don't blur.
|
||||
|
||||
const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1);
|
||||
|
||||
@@ -1286,9 +1293,31 @@ export function HealthChart({
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : !chartData?.length ? null : (chartData?.length ?? 0) < 3 ? (
|
||||
) : !chartData?.length ? (
|
||||
// v1.4.43 W11-M6 — empty-window state.
|
||||
//
|
||||
// Pre-fix the chart silently `return null`ed when `chartData`
|
||||
// resolved empty (after-load, no points in the selected
|
||||
// range). Paired with the < 3-points hint, the user's
|
||||
// five visible chart states were:
|
||||
//
|
||||
// - skeleton (loading)
|
||||
// - 1-2 daily points / many raw → "Mehr Messtage erforderlich"
|
||||
// - 1-2 daily points / < 3 raw → "Erfasse mindestens 3 Einträge"
|
||||
// - ≥ 3 daily points → line chart
|
||||
// - 0 daily points → NOTHING
|
||||
//
|
||||
// The "nothing" branch read as a broken widget on the
|
||||
// dashboard. Paint a distinct empty state explaining the
|
||||
// selected range is empty so the user reaches for the range
|
||||
// tabs or the quick-add instead of suspecting a bug.
|
||||
<ChartEmptyState
|
||||
title={t("charts.noDataInRangeTitle")}
|
||||
description={t("charts.noDataInRangeDescription")}
|
||||
/>
|
||||
) : (chartData?.length ?? 0) < 3 ? (
|
||||
// v1.4.16 B1a — sparse-data placeholder. <3 daily points is too
|
||||
// few to render a meaningful trend; paint a friendly hint
|
||||
// instead so the dashboard doesn't look broken.
|
||||
|
||||
@@ -123,9 +123,12 @@ export function RecentWorkoutsTile() {
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
// v1.4.43 W11-L6 — reserve roughly the loaded tile height
|
||||
// (3 workouts × ~3 rem rows + spacing) so the surrounding
|
||||
// dashboard layout doesn't reflow once the data lands.
|
||||
<p
|
||||
data-slot="recent-workouts-loading"
|
||||
className="text-muted-foreground text-xs"
|
||||
className="text-muted-foreground min-h-[10rem] text-xs"
|
||||
>
|
||||
{t("dashboard.recentWorkouts.loading")}
|
||||
</p>
|
||||
|
||||
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user