From 1440c77396d070ae6e4421324e113c2503a4b901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 17:18:11 +0200 Subject: [PATCH] fix(measurements): refine drill-down limit to 1000 in the validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/measurements per-day drill-down branch clamped `limit` to `Math.min(limit, 1000)` after the request reached the route. Callers asking for more saw no response signal — the schema-declared 5000 ceiling silently capped at 1000. Move the cap into the validator's .refine on (limit, dayKey) so the boundary returns 422 with an actionable message. The route now reads `limit` straight through because the validator has already clamped it. --- src/app/api/measurements/route.ts | 8 +++-- .../validations/__tests__/measurement.test.ts | 36 +++++++++++++++++++ src/lib/validations/measurement.ts | 14 +++++++- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 0c7ecab10..6edebf6a0 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -100,9 +100,11 @@ export const GET = apiHandler(async (request: NextRequest) => { measuredAt: { gte: dayStart, lt: dayEnd }, }, orderBy: { measuredAt: sortDir }, - // Cap the drill-down — a phone-only stepCount day can hit a few - // hundred rows; the route ceiling keeps a pathological day bounded. - take: Math.min(limit, 1000), + // v1.4.38 — the 1000-row cap now lives on the validator (refine + // on `(limit, dayKey)` returning 422 when a caller asks for + // more). The route reads `limit` straight through because the + // validator has already clamped it. + take: limit, }); annotate({ action: { name: "measurement.list" }, diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index b2e7235dc..7137e5e02 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -235,4 +235,40 @@ describe("measurement validation", () => { expect(parsed.success).toBe(false); }); }); + + // v1.4.38 — the per-day drill-down branch always returns at most + // 1000 rows (one pathological phone-only stepCount day). Push the + // cap into the validator so a caller asking for more sees a 422 + // instead of a silent server-side Math.min clamp. + describe("listMeasurementsSchema — v1.4.38 dayKey limit ceiling", () => { + it("accepts limit=1000 alongside a dayKey", () => { + const parsed = listMeasurementsSchema.safeParse({ + type: "ACTIVITY_STEPS", + dayKey: "2026-05-15", + limit: 1000, + }); + expect(parsed.success).toBe(true); + }); + + it("rejects limit=1001 when dayKey is set (drill-down hard cap)", () => { + const parsed = listMeasurementsSchema.safeParse({ + type: "ACTIVITY_STEPS", + dayKey: "2026-05-15", + limit: 1001, + }); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues[0].message).toMatch(/<=\s*1000/); + expect(parsed.error.issues[0].path).toEqual(["limit"]); + } + }); + + it("accepts limit=5000 when dayKey is omitted (legacy ceiling)", () => { + const parsed = listMeasurementsSchema.safeParse({ + type: "ACTIVITY_STEPS", + limit: 5000, + }); + expect(parsed.success).toBe(true); + }); + }); }); diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index ec6ee64ee..28956f222 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -325,7 +325,19 @@ export const listMeasurementsSchema = z "offset is not supported with groupBy=day or dayKey; use a smaller window or omit offset", path: ["offset"], }, - ); + ) + // v1.4.38 — surface the drill-down cap on the validator so a caller + // asking for more than 1000 rows on a per-day branch sees a 422 + // instead of a silent `Math.min(limit, 1000)` clamp inside the + // route. The drill-down returns at most one phone-only stepCount + // day's worth of per-sample chunks; 1000 covers the pathological + // case with room to spare. Callers that want a wider window should + // omit `dayKey` and use `from` / `to` instead. + .refine(({ limit, dayKey }) => !(dayKey != null && limit > 1000), { + message: + "limit must be <= 1000 when dayKey is set; drill-down responses are capped at 1000 rows", + path: ["limit"], + }); export const createBatchMeasurementSchema = z.object({ measurements: z.array(createMeasurementSchema).min(1).max(5),