release: v1.32.0 — hardening and scalability (#580)

* release: prepare v1.32.0

* test: stabilize water capture re-entry
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-22 04:14:15 +02:00
1 parent b01822be48
commit 62eb431d66
372 files changed
+25178 -8561

No files matched your search

@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: { user: { update: vi.fn() } },
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
setOnboardingPendingCookie: 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 as disclaimerPost } from "../disclaimer/route";
import { POST as tourPost } from "../tour/route";
import { POST as completePost } from "../complete/route";
import { getSession } from "@/lib/auth/session";
const SESSION = {
session: { id: "s1", expiresAt: new Date(Date.now() + 60_000) },
user: {
id: "u1",
username: "user",
role: "USER" as const,
onboardingTourCompleted: false,
},
};
function req(path: string, body: unknown) {
return new NextRequest(`http://localhost/api/onboarding/${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION as never);
});
describe("onboarding validation error contract", () => {
it("pins disclaimer validation", async () => {
const res = await disclaimerPost(req("disclaimer", {}));
expect(res.status).toBe(422);
expect((await res.json()).meta?.errorCode).toBe(
"onboarding.disclaimer.invalid",
);
});
it("pins tour validation", async () => {
const res = await tourPost(req("tour", {}));
expect(res.status).toBe(422);
expect((await res.json()).meta?.errorCode).toBe("onboarding.tour.invalid");
});
it("pins legacy completion validation", async () => {
const res = await completePost(req("complete", { heightCm: 20 }));
expect(res.status).toBe(422);
expect((await res.json()).meta?.errorCode).toBe(
"onboarding.complete.invalid",
);
});
});
+3 -1
View File
@@ -28,7 +28,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const result = z.safeParse(onboardingSchema, body);
if (!result.success) {
return apiError("Invalid input", 422);
return apiError("Invalid input", 422, {
errorCode: "onboarding.complete.invalid",
});
}
const data: Record<string, unknown> = {
+3 -1
View File
@@ -36,7 +36,9 @@ export const POST = apiHandler(async (request) => {
const parsed = z.safeParse(bodySchema, rawBody);
if (!parsed.success) {
return apiError("Invalid input", 422);
return apiError("Invalid input", 422, {
errorCode: "onboarding.disclaimer.invalid",
});
}
// The server pins the canonical version it stamps — the body version is
+16 -5
View File
@@ -73,7 +73,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "onboarding.step" },
meta: { outcome: "rate_limited" },
});
return apiError("Too many onboarding writes, try again later", 429);
return apiError("Too many onboarding writes, try again later", 429, {
errorCode: "onboarding.step.rateLimited",
});
}
const { data: body, error: jsonError } = await safeJson<unknown>(request, {
@@ -87,7 +89,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "onboarding.step" },
meta: { outcome: "validation_failed" },
});
return apiError("Invalid step payload", 422);
return apiError("Invalid step payload", 422, {
errorCode: "onboarding.step.invalid",
});
}
const { step, goals } = parsed.data;
@@ -102,7 +106,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
if (!fresh) {
return apiError("User not found", 404);
return apiError("User not found", 404, {
errorCode: "onboarding.user.notFound",
});
}
if (fresh.onboardingCompletedAt) {
@@ -110,7 +116,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "onboarding.step" },
meta: { outcome: "already_completed" },
});
return apiError("Onboarding already completed", 409);
return apiError("Onboarding already completed", 409, {
errorCode: "onboarding.step.completed",
});
}
const current = fresh.onboardingStep;
@@ -119,7 +127,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "onboarding.step" },
meta: { outcome: "out_of_order", current, requested: step },
});
return apiError(`Step out of order — current step is ${current}`, 409);
return apiError(`Step out of order — current step is ${current}`, 409, {
errorCode: "onboarding.step.outOfOrder",
});
}
const completing = step === 4;
@@ -159,6 +169,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError(
"Onboarding step changed concurrently, refresh and retry",
409,
{ errorCode: "onboarding.step.concurrent" },
);
}
const updated = await prisma.user.findUniqueOrThrow({
+3 -1
View File
@@ -55,7 +55,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = z.safeParse(tourBodySchema, body);
if (!parsed.success) {
return apiError("Invalid input", 422);
return apiError("Invalid input", 422, {
errorCode: "onboarding.tour.invalid",
});
}
// Build the update field-by-field (no mass assignment). `completed`