fix(api): declare the 400 and 415 the single-value profile writes answer

The six /api/auth/me scalar writes (unit preference, glucose unit,
automatic document reading, local lab OCR, workout routes, the central
Codex switch) read their body through safeJson with a 1 KB cap, so each
answers 415 for a body that is not JSON by content type and 400 for one
that does not parse, beside the 413 they already declared. Their
descriptions said so and the responses did not. All three now come from
one block.

The modules and disable-coach route tests had been skipping the
contract check over a 400 and a 409 the registry already declares; the
skips are gone.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 07:07:46 +02:00
1 parent 5ca3994523
commit 579789ec38
5 files changed
+300 -320

No files matched your search

+266 -269
View File
File diff suppressed because it is too large. Load diff
+29 -24
View File
@@ -1141,6 +1141,29 @@ const profileSummaryResponse = z
"The bounded profile view a record viewer gets: allergies, family history and current lifestyle facts. Structurally incapable of becoming a Settings or AI surface.",
});
/**
* What the single-value `/api/auth/me/*` writes answer for a body they refuse
* to read. Each reads through `safeJson` with a 1 KB cap, so the three
* refusals are the cap's, not the route's: a wrong content type is 415, a body
* over the cap 413, and one that does not parse 400. All three are answered
* before validation, and nothing is written.
*/
const cappedScalarBodyRefusals = {
"400": {
description: "The request body is not parseable JSON. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
"415": {
description:
"The request did not carry `Content-Type: application/json`. Nothing was read and nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/profile/summary": {
get: {
@@ -1863,10 +1886,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -1917,10 +1937,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -1971,10 +1988,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -2257,10 +2271,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -2307,10 +2318,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -2360,10 +2368,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...cappedScalarBodyRefusals,
...stdResponses,
},
},
@@ -174,9 +174,6 @@ describe("PATCH /api/auth/me/disable-coach", () => {
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
contract: {
skip: "the registry declares no 400 for this PATCH, which answers one for a body that is not JSON",
},
});
expect(res.status).toBe(400);
expect(res.error).toBe("Invalid JSON body");
@@ -37,11 +37,6 @@ type ModulesResponse = {
updatedAt?: string;
};
const NO_400 =
"the registry declares no 400 for PATCH /api/auth/me/modules although the handler answers one for a body that is not JSON";
const NO_409 =
"the registry declares no 409 for PATCH /api/auth/me/modules although the handler answers modules_conflict for a stale base token";
beforeEach(() => truncateAllTables(prisma));
async function storedPrefs(userId: string): Promise<unknown> {
@@ -63,14 +58,12 @@ async function storedUpdatedAt(userId: string): Promise<Date> {
async function patch(
auth: Awaited<ReturnType<typeof makeSession>>,
body: Record<string, unknown>,
contract?: { skip: string },
) {
return callRoute<ModulesResponse>(PATCH, {
method: "PATCH",
route: ROUTE,
body,
auth,
contract,
});
}
@@ -229,7 +222,6 @@ describe("PATCH /api/auth/me/modules", () => {
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
contract: { skip: NO_400 },
});
expect(res.status).toBe(400);
expect(res.error).toBe("Invalid JSON body");
@@ -281,11 +273,10 @@ describe("PATCH /api/auth/me/modules evicts the cached score", () => {
const user = await makeUser();
caches.analytics.set(`${user.id}|score`, { score: 71 });
const res = await patch(
await makeSession(user),
{ sleep: false, baseUpdatedAt: "2020-01-01T00:00:00.000Z" },
{ skip: NO_409 },
);
const res = await patch(await makeSession(user), {
sleep: false,
baseUpdatedAt: "2020-01-01T00:00:00.000Z",
});
expect(res.status).toBe(409);
expect(caches.analytics.get(`${user.id}|score`)).toEqual({ score: 71 });
});
@@ -320,11 +311,7 @@ describe("PATCH /api/auth/me/modules optimistic concurrency", () => {
const first = await patch(session, { mood: false, baseUpdatedAt: base });
expect(first.status).toBe(200);
const second = await patch(
session,
{ sleep: false, baseUpdatedAt: base },
{ skip: NO_409 },
);
const second = await patch(session, { sleep: false, baseUpdatedAt: base });
expect(second.status).toBe(409);
expect(second.meta?.errorCode).toBe("modules_conflict");
expect(await storedPrefs(user.id)).toEqual({ mood: false });
@@ -171,9 +171,6 @@ describe("PATCH /api/auth/me/unit-preference", () => {
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
contract: {
skip: "the registry declares no 400 for this PATCH although the description names it",
},
});
expect(res.status).toBe(400);
expect(await storedPreference(user.id)).toBe("metric");
@@ -199,9 +196,6 @@ describe("PATCH /api/auth/me/unit-preference", () => {
body: "unitPreference=imperial",
headers: { "content-type": "application/x-www-form-urlencoded" },
auth: await makeSession(user),
contract: {
skip: "the registry declares no 415 for this PATCH although the description names it",
},
});
expect(res.status).toBe(415);
expect(await storedPreference(user.id)).toBe("metric");