fix(api): pass aggregate grain as a SQL literal to date_trunc

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 10:34:46 +02:00
1 parent 8a3bea3415
commit 6e75463fef
2 files changed
+213 -3

No files matched your search

+17 -3
View File
@@ -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<Exclude<AggregateGrain, "raw">, 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}
@@ -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");
});
});