From 6e75463fefc71c396eec247f0da409770332bc9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 10:34:46 +0200 Subject: [PATCH] fix(api): pass aggregate grain as a SQL literal to date_trunc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `aggregate=daily|weekly|monthly` branch on `GET /api/measurements` 500'd in production for every grain. Two root causes that the mocked-`$queryRaw` unit suite hid end-to-end: 1. `date_trunc(${truncUnit}, …)` passed the grain as a bound parameter; Postgres rejects — the unit argument must be a SQL literal. Switch to `Prisma.raw` for the unit string; safe because the grain is upstream-restricted to the `AggregateGrain` Zod enum. 2. The `daily → day` mapping ignored the `weekly` and `monthly` forms; Postgres rejects those as units. Map all three grains to the singular Postgres unit (`day` / `week` / `month`). The user-id cast `${type}::"MeasurementType"` also referenced a non-existent type name; the actual Postgres enum is `measurement_type`. Corrected alongside. Add `tests/integration/measurements-aggregate-daily.test.ts` against a real Postgres so every grain compiles + executes end-to-end. The mocked unit suite stays for the route-shape contracts. --- src/app/api/measurements/route.ts | 20 +- .../measurements-aggregate-daily.test.ts | 196 ++++++++++++++++++ 2 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 tests/integration/measurements-aggregate-daily.test.ts diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 5e0d6f377..b68eac336 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -74,20 +74,34 @@ export const GET = apiHandler(async (request: NextRequest) => { if (aggregate && aggregate !== "raw" && from && to) { const grain: AggregateGrain = aggregate; const cap = Math.min(limit, BUCKET_CAP[grain]); - const truncUnit = grain === "daily" ? "day" : grain; + // Postgres `date_trunc` requires the unit argument to be a SQL + // literal — it cannot be supplied as a prepared-statement parameter. + // The previous `${truncUnit}` (bound) interpolation 500'd in + // production for every grain; the grain string is restricted to + // the `AggregateGrain` enum upstream via the Zod schema, so + // injecting the mapped unit via `Prisma.raw` is safe. The mapping + // also fixes the "weekly"/"monthly" passthrough — Postgres expects + // the singular forms `week`/`month`. + const TRUNC_UNIT: Record, string> = { + daily: "day", + weekly: "week", + monthly: "month", + }; + const truncUnit = TRUNC_UNIT[grain]; + const truncUnitLiteral = Prisma.raw(`'${truncUnit}'`); const buckets = await prisma.$queryRaw< Array<{ type: string; bucket_start: Date; avg: number; cnt: number }> >` SELECT m."type"::text AS type, - date_trunc(${truncUnit}, m."measured_at") AS bucket_start, + date_trunc(${truncUnitLiteral}, m."measured_at") AS bucket_start, AVG(m."value")::double precision AS avg, COUNT(*)::int AS cnt FROM measurements m WHERE m."user_id" = ${user.id} AND m."measured_at" >= ${from} AND m."measured_at" <= ${to} - ${type ? Prisma.sql`AND m."type" = ${type}::"MeasurementType"` : Prisma.empty} + ${type ? Prisma.sql`AND m."type" = ${type}::measurement_type` : Prisma.empty} GROUP BY m."type", bucket_start ORDER BY bucket_start ASC LIMIT ${cap} diff --git a/tests/integration/measurements-aggregate-daily.test.ts b/tests/integration/measurements-aggregate-daily.test.ts new file mode 100644 index 000000000..3069f9c00 --- /dev/null +++ b/tests/integration/measurements-aggregate-daily.test.ts @@ -0,0 +1,196 @@ +/** + * v1.4.29 C2 — `aggregate=daily` against a real Postgres. + * + * The mocked-`$queryRaw` unit test in + * `src/app/api/measurements/__tests__/range-aggregation-route.test.ts` + * passes because nothing exercises the actual SQL. In production the + * `date_trunc(${truncUnit}, …)` call 500'd for every grain because + * Postgres rejects a bound parameter for `date_trunc`'s unit argument. + * + * This suite hits the route against a real database so the SQL has + * to compile + execute. It would fail on the pre-fix code and passes + * after switching `${truncUnit}` from a bound parameter to a SQL + * literal via `Prisma.raw`. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-aggregate-daily-test"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "aggregate-daily", + email: "aggregate-daily@example.test", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function getRequest(query: string): NextRequest { + return new NextRequest(`http://localhost/api/measurements?${query}`, { + method: "GET", + }); +} + +describe("GET /api/measurements?aggregate=… (real Postgres)", () => { + it("returns bucketed rows for grain=daily without 500'ing on date_trunc", async () => { + // Two pulse rows on the same calendar day plus one on a second + // day — three rows total, two distinct daily buckets. + const prisma = getPrismaClient(); + await prisma.measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "PULSE", + value: 60, + unit: "bpm", + source: "MANUAL", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + }, + { + userId: TEST_USER_ID, + type: "PULSE", + value: 80, + unit: "bpm", + source: "MANUAL", + measuredAt: new Date("2026-05-01T20:00:00.000Z"), + }, + { + userId: TEST_USER_ID, + type: "PULSE", + value: 70, + unit: "bpm", + source: "MANUAL", + measuredAt: new Date("2026-05-02T12:00:00.000Z"), + }, + ], + }); + + const { GET } = await import("@/app/api/measurements/route"); + const response = await GET( + getRequest( + "type=PULSE&from=2026-05-01T00:00:00Z&to=2026-05-03T00:00:00Z&aggregate=daily&limit=365", + ), + ); + + // Pre-fix the response was 500; post-fix it must be 200 with two + // daily buckets. + expect(response.status).toBe(200); + const json = (await response.json()) as { + data: { + measurements: Array<{ + type: string; + value: number; + measuredAt: string; + count: number; + }>; + meta: { aggregate?: string }; + }; + }; + expect(json.data.meta.aggregate).toBe("daily"); + expect(json.data.measurements).toHaveLength(2); + + const may1 = json.data.measurements.find((m) => + m.measuredAt.startsWith("2026-05-01"), + ); + const may2 = json.data.measurements.find((m) => + m.measuredAt.startsWith("2026-05-02"), + ); + expect(may1).toBeDefined(); + expect(may1!.count).toBe(2); + expect(may2).toBeDefined(); + expect(may2!.count).toBe(1); + }); + + it("returns bucketed rows for grain=weekly", async () => { + const prisma = getPrismaClient(); + await prisma.measurement.create({ + data: { + userId: TEST_USER_ID, + type: "WEIGHT", + value: 80, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + }, + }); + + const { GET } = await import("@/app/api/measurements/route"); + const response = await GET( + getRequest( + "type=WEIGHT&from=2026-04-01T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=weekly&limit=105", + ), + ); + + expect(response.status).toBe(200); + const json = (await response.json()) as { + data: { meta: { aggregate?: string } }; + }; + expect(json.data.meta.aggregate).toBe("weekly"); + }); + + it("returns bucketed rows for grain=monthly", async () => { + const prisma = getPrismaClient(); + await prisma.measurement.create({ + data: { + userId: TEST_USER_ID, + type: "WEIGHT", + value: 80, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + }, + }); + + const { GET } = await import("@/app/api/measurements/route"); + const response = await GET( + getRequest( + "type=WEIGHT&from=2025-01-01T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=monthly&limit=24", + ), + ); + + expect(response.status).toBe(200); + const json = (await response.json()) as { + data: { meta: { aggregate?: string } }; + }; + expect(json.data.meta.aggregate).toBe("monthly"); + }); +});