mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
561 lines
20 KiB
TypeScript
561 lines
20 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
import { NextRequest } from "next/server";
|
||
|
||
/**
|
||
* v1.4.28 R4-CODE-C1 / C2 — the `/api/measurements` route must
|
||
* aggregate via Postgres `date_trunc` BEFORE applying the bucket cap.
|
||
* The legacy code path `findMany({ take: limit })` then in-memory
|
||
* bucketised — a 1-year `aggregate=daily` window would silently truncate
|
||
* to the first N raw rows and surface only a handful of buckets.
|
||
*
|
||
* The route now runs a `$queryRaw` with `GROUP BY type, date_trunc(...)`
|
||
* so the cap (`BUCKET_CAP[grain]`) applies after the bucket count is
|
||
* known. C2 — the aggregation must NOT auto-fire on iOS callers; the
|
||
* `aggregate` param is required to opt in.
|
||
*/
|
||
|
||
vi.mock("@/lib/db", () => ({
|
||
prisma: {
|
||
measurement: {
|
||
findMany: vi.fn(),
|
||
count: vi.fn(),
|
||
},
|
||
measurementRollup: {
|
||
findMany: vi.fn(),
|
||
},
|
||
// v1.11.1 — the rollup path calls `loadUserSourcePriority`
|
||
// (prisma.user.findUnique) to build the source-rank ladders before
|
||
// `collapseRollupRowsBySource` runs. This is a separate read from the
|
||
// date_trunc `$queryRaw`; `null` → default ladders.
|
||
user: { findUnique: vi.fn() },
|
||
$queryRaw: vi.fn(),
|
||
},
|
||
}));
|
||
|
||
// v1.4.39.2 — the rollup branch's coverage-mismatch fallback may call
|
||
// `recomputeUserRollups` inline. Mock it so tests can assert whether
|
||
// the inline fold fired without exercising the real populator.
|
||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||
const actual = await vi.importActual<
|
||
typeof import("@/lib/rollups/measurement-rollups")
|
||
>("@/lib/rollups/measurement-rollups");
|
||
return {
|
||
...actual,
|
||
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
||
recomputeUserRollups: vi
|
||
.fn()
|
||
.mockResolvedValue({ rowsUpserted: 0, durationMs: 0 }),
|
||
};
|
||
});
|
||
|
||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||
vi.mock("@/lib/auth/audit", () => ({ auditLog: 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 { GET } from "../route";
|
||
import { prisma } from "@/lib/db";
|
||
import { getSession } from "@/lib/auth/session";
|
||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||
|
||
const SESSION_OK = {
|
||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||
user: { id: "user-1", username: "test-user", role: "USER" as const },
|
||
};
|
||
|
||
function getRequest(query: string): NextRequest {
|
||
return new NextRequest(`http://localhost/api/measurements?${query}`, {
|
||
method: "GET",
|
||
});
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.resetAllMocks();
|
||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||
// v1.11.1 — null source-priority blob → default rank ladders for the
|
||
// rollup-path source collapse.
|
||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||
});
|
||
|
||
describe("GET /api/measurements — aggregation gate (C2)", () => {
|
||
it("returns raw rows when `aggregate` is omitted, even on a wide window", async () => {
|
||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||
{
|
||
id: "m-1",
|
||
userId: "user-1",
|
||
type: "PULSE",
|
||
value: 60,
|
||
measuredAt: new Date("2026-01-01T10:00:00.000Z"),
|
||
source: "MANUAL",
|
||
notes: null,
|
||
unit: "bpm",
|
||
},
|
||
] as never);
|
||
vi.mocked(prisma.measurement.count).mockResolvedValue(1 as never);
|
||
|
||
// A 1-year window with no `aggregate` must NOT trigger the
|
||
// aggregation branch — iOS contract safety (R4-CODE-C2).
|
||
const res = await GET(
|
||
getRequest(
|
||
"type=PULSE&from=2025-05-15T00:00:00Z&to=2026-05-15T00:00:00Z&limit=500",
|
||
),
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
const json = (await res.json()) as {
|
||
data: { measurements: Array<unknown>; meta: { aggregate?: string } };
|
||
};
|
||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
|
||
expect(json.data.meta.aggregate).toBeUndefined();
|
||
});
|
||
|
||
it("runs SQL aggregation when `aggregate=daily` is set and returns bucketed rows", async () => {
|
||
// Simulate 365 daily buckets returned from the date_trunc query.
|
||
const buckets = Array.from({ length: 365 }, (_, i) => ({
|
||
type: "PULSE",
|
||
bucket_start: new Date(
|
||
Date.UTC(2025, 4, 15 + i, 0, 0, 0) - i * 0, // placeholder, monotonic
|
||
),
|
||
avg: 60 + (i % 10),
|
||
cnt: 1440, // a minute-by-minute pulse account
|
||
}));
|
||
vi.mocked(prisma.$queryRaw).mockResolvedValue(buckets as never);
|
||
|
||
const res = await GET(
|
||
getRequest(
|
||
"type=PULSE&from=2025-05-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&limit=5000",
|
||
),
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
const json = (await res.json()) as {
|
||
data: {
|
||
measurements: Array<{ type: string; value: number; count: number }>;
|
||
meta: { aggregate?: string; limit: number };
|
||
};
|
||
};
|
||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||
// C1 — the response carries one bucket per calendar day, NOT
|
||
// `min(limit, count)/grain`. The raw-data density (1440 rows/day)
|
||
// is summarised in the `count` field.
|
||
expect(json.data.measurements.length).toBe(365);
|
||
expect(json.data.measurements[0].count).toBe(1440);
|
||
expect(json.data.meta.aggregate).toBe("daily");
|
||
// Bucket cap for daily is 365.
|
||
expect(json.data.meta.limit).toBe(365);
|
||
});
|
||
|
||
it("caps monthly grain at the BUCKET_CAP ceiling", async () => {
|
||
const buckets = Array.from({ length: 24 }, (_, i) => ({
|
||
type: "WEIGHT",
|
||
bucket_start: new Date(Date.UTC(2024, i, 1, 0, 0, 0)),
|
||
avg: 75 + i * 0.1,
|
||
cnt: 30,
|
||
}));
|
||
vi.mocked(prisma.$queryRaw).mockResolvedValue(buckets as never);
|
||
|
||
const res = await GET(
|
||
getRequest(
|
||
"type=WEIGHT&from=2024-01-01T00:00:00Z&to=2026-01-01T00:00:00Z&aggregate=monthly&limit=5000",
|
||
),
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
const json = (await res.json()) as {
|
||
data: { meta: { limit: number; aggregate?: string } };
|
||
};
|
||
expect(json.data.meta.limit).toBe(24);
|
||
expect(json.data.meta.aggregate).toBe("monthly");
|
||
});
|
||
});
|
||
|
||
describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
|
||
it("returns a sensible monthly series for a multi-year all-time window", async () => {
|
||
// Simulate four years of monthly weight rollups. The fixture mirrors
|
||
// what `date_trunc('month', measured_at)` produces server-side and
|
||
// proves the route hands back the full history (capped at the
|
||
// monthly BUCKET_CAP of 24 — the chart paints the most recent 24
|
||
// months when "All time" exceeds the cap).
|
||
const buckets = Array.from({ length: 24 }, (_, i) => ({
|
||
type: "WEIGHT",
|
||
bucket_start: new Date(Date.UTC(2024, i, 1, 0, 0, 0)),
|
||
avg: 80 - i * 0.05,
|
||
cnt: 30,
|
||
}));
|
||
vi.mocked(prisma.$queryRaw).mockResolvedValue(buckets as never);
|
||
|
||
// "All time" client call: oldest measurement → today, monthly grain.
|
||
const res = await GET(
|
||
getRequest(
|
||
"type=WEIGHT&from=2020-01-01T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=monthly&limit=5000",
|
||
),
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
const json = (await res.json()) as {
|
||
data: {
|
||
measurements: Array<{ measuredAt: string; count: number }>;
|
||
meta: { aggregate?: string; total: number };
|
||
};
|
||
};
|
||
// The response is monthly-bucketed (NOT a truncated 365-day slice).
|
||
expect(json.data.meta.aggregate).toBe("monthly");
|
||
expect(json.data.measurements.length).toBe(24);
|
||
// Buckets cover distinct calendar months — a 365-day slice could
|
||
// not span more than 13 months.
|
||
const months = new Set(
|
||
json.data.measurements.map((m) => m.measuredAt.slice(0, 7)),
|
||
);
|
||
expect(months.size).toBe(24);
|
||
});
|
||
|
||
it("source=rollup + aggregate=daily reads measurement_rollups without firing the heavy date_trunc query", async () => {
|
||
// v1.4.36 W1 — Insights trends row routes the three daily chart
|
||
// fetches through the persistent DAY buckets via `source=rollup`.
|
||
// The heavy `date_trunc` $queryRaw must NOT fire on the happy
|
||
// path where the rollup has rows for the requested window.
|
||
const buckets = Array.from({ length: 30 }, (_, i) => ({
|
||
type: "WEIGHT",
|
||
bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)),
|
||
mean: 81 + i * 0.05,
|
||
count: 5,
|
||
// v1.11.1 — single source per day → collapse is identity.
|
||
source: "APPLE_HEALTH",
|
||
}));
|
||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||
buckets as never,
|
||
);
|
||
|
||
const res = await GET(
|
||
getRequest(
|
||
"type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||
),
|
||
);
|
||
expect(res.status).toBe(200);
|
||
const json = (await res.json()) as {
|
||
data: {
|
||
measurements: Array<{ type: string; value: number; count: number }>;
|
||
meta: { aggregate?: string };
|
||
};
|
||
};
|
||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
|
||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||
expect(json.data.measurements.length).toBe(30);
|
||
// First bucket reflects the rollup mean — round-trip-stable.
|
||
expect(json.data.measurements[0].type).toBe("WEIGHT");
|
||
expect(json.data.measurements[0].value).toBeCloseTo(81, 5);
|
||
expect(json.data.measurements[0].count).toBe(5);
|
||
expect(json.data.meta.aggregate).toBe("daily");
|
||
});
|
||
|
||
it("source=rollup cumulative path reads sum_value directly (v1.4.39 W-SUM)", async () => {
|
||
// ACTIVITY_STEPS rollup row with sum_value populated. The route
|
||
// must consume sumValue directly — NOT reconstruct from mean *
|
||
// count. Use a sum that is NOT mean × count so the test
|
||
// distinguishes the two paths.
|
||
const buckets = Array.from({ length: 3 }, (_, i) => ({
|
||
type: "ACTIVITY_STEPS",
|
||
bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)),
|
||
mean: 2000, // would yield 10000 if multiplied by count=5
|
||
count: 5,
|
||
sumValue: 11000 + i * 250, // distinct from mean × count
|
||
source: "APPLE_HEALTH",
|
||
}));
|
||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||
buckets as never,
|
||
);
|
||
|
||
const res = await GET(
|
||
getRequest(
|
||
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||
),
|
||
);
|
||
expect(res.status).toBe(200);
|
||
const json = (await res.json()) as {
|
||
data: { measurements: Array<{ value: number }> };
|
||
};
|
||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||
expect(json.data.measurements.map((m) => m.value)).toEqual([
|
||
11000, 11250, 11500,
|
||
]);
|
||
});
|
||
|
||
it("source=rollup cumulative path falls back to mean*count when sum_value is NULL (v1.4.39 W-SUM)", async () => {
|
||
// Pre-v1.4.39 row — boot-backfill hasn't converged yet. The
|
||
// route falls back to mean × count so the chart never paints
|
||
// a hole during the convergence window.
|
||
const buckets = [
|
||
{
|
||
type: "ACTIVITY_STEPS",
|
||
bucketStart: new Date(Date.UTC(2026, 4, 1)),
|
||
mean: 2000,
|
||
count: 5,
|
||
sumValue: null,
|
||
source: "APPLE_HEALTH",
|
||
},
|
||
];
|
||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||
buckets as never,
|
||
);
|
||
|
||
const res = await GET(
|
||
getRequest(
|
||
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||
),
|
||
);
|
||
const json = (await res.json()) as {
|
||
data: { measurements: Array<{ value: number }> };
|
||
};
|
||
expect(json.data.measurements[0].value).toBe(10000);
|
||
});
|
||
|
||
it("falls back to live date_trunc when source=rollup returns zero buckets", async () => {
|
||
// Empty rollup ⇒ heavy aggregate runs so brand-new accounts still
|
||
// see a correct chart on their first render.
|
||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||
const liveBuckets = Array.from({ length: 7 }, (_, i) => ({
|
||
type: "WEIGHT",
|
||
bucket_start: new Date(Date.UTC(2026, 4, 8 + i, 0, 0, 0)),
|
||
avg: 81 + i * 0.05,
|
||
cnt: 2,
|
||
}));
|
||
vi.mocked(prisma.$queryRaw).mockResolvedValue(liveBuckets as never);
|
||
|
||
const res = await GET(
|
||
getRequest(
|
||
"type=WEIGHT&from=2026-05-08T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||
),
|
||
);
|
||
expect(res.status).toBe(200);
|
||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
|
||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||
const json = (await res.json()) as {
|
||
data: { measurements: Array<unknown> };
|
||
};
|
||
expect(json.data.measurements.length).toBe(7);
|
||
});
|
||
|
||
it("source=rollup folds the partition inline and re-reads when the rollup is under-converged (v1.4.39.2)", async () => {
|
||
// The maintainer's pre-fix production trace: 30-day BP_SYS window returned
|
||
// `total: 2` from the rollup branch even though `measurements`
|
||
// carried ~30 distinct days. The chart tripped its `< 3 daily
|
||
// points` empty-state because the route short-circuited on
|
||
// `rollupRows.length > 0` and never reconciled with the live
|
||
// table. The coverage-mismatch fallback runs an inline
|
||
// `recomputeUserRollups` for the (user, type, DAY, window)
|
||
// partition and re-reads so the request returns the converged
|
||
// rows.
|
||
const sparseRollup = [
|
||
{
|
||
type: "BLOOD_PRESSURE_SYS",
|
||
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
|
||
mean: 128,
|
||
count: 1,
|
||
sumValue: null,
|
||
source: "APPLE_HEALTH",
|
||
},
|
||
{
|
||
type: "BLOOD_PRESSURE_SYS",
|
||
bucketStart: new Date(Date.UTC(2026, 4, 10, 0, 0, 0)),
|
||
mean: 132,
|
||
count: 1,
|
||
sumValue: null,
|
||
source: "APPLE_HEALTH",
|
||
},
|
||
];
|
||
const convergedRollup = Array.from({ length: 28 }, (_, i) => ({
|
||
type: "BLOOD_PRESSURE_SYS",
|
||
bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)),
|
||
mean: 128 + (i % 5),
|
||
count: 2,
|
||
sumValue: null,
|
||
source: "APPLE_HEALTH",
|
||
}));
|
||
// First read returns the sparse rows; second (post-fold) read
|
||
// returns the converged rows.
|
||
vi.mocked(prisma.measurementRollup.findMany)
|
||
.mockResolvedValueOnce(sparseRollup as never)
|
||
.mockResolvedValueOnce(convergedRollup as never);
|
||
// Coverage probe — live `measurements` has 28 distinct days.
|
||
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
|
||
{ days: BigInt(28) },
|
||
] as never);
|
||
|
||
const res = await GET(
|
||
getRequest(
|
||
"type=BLOOD_PRESSURE_SYS&from=2026-04-30T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||
),
|
||
);
|
||
expect(res.status).toBe(200);
|
||
const json = (await res.json()) as {
|
||
data: { measurements: Array<unknown> };
|
||
};
|
||
// The inline fold fired exactly once with the (type, DAY, window)
|
||
// bound so the partition write stays small.
|
||
expect(recomputeUserRollups).toHaveBeenCalledTimes(1);
|
||
expect(recomputeUserRollups).toHaveBeenCalledWith(
|
||
"user-1",
|
||
expect.objectContaining({
|
||
types: ["BLOOD_PRESSURE_SYS"],
|
||
granularities: ["DAY"],
|
||
}),
|
||
);
|
||
// The route re-read the rollup after the fold so the response
|
||
// carries the converged 28 buckets — well above the chart's
|
||
// `< 3 daily points` empty-state threshold.
|
||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(2);
|
||
expect(json.data.measurements.length).toBe(28);
|
||
});
|
||
|
||
it("source=rollup skips the coverage probe on a covered window (v1.4.39.2 hot-path)", async () => {
|
||
// Happy case: 30-day window, 28 rollup rows present. The route
|
||
// must short-circuit on the first read and skip the probe so the
|
||
// covered-tenant hot path stays a single indexed read.
|
||
const buckets = Array.from({ length: 28 }, (_, i) => ({
|
||
type: "WEIGHT",
|
||
bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)),
|
||
mean: 81 + i * 0.05,
|
||
count: 2,
|
||
sumValue: null,
|
||
source: "APPLE_HEALTH",
|
||
}));
|
||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||
buckets as never,
|
||
);
|
||
|
||
const res = await GET(
|
||
getRequest(
|
||
"type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||
),
|
||
);
|
||
expect(res.status).toBe(200);
|
||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
|
||
expect(recomputeUserRollups).not.toHaveBeenCalled();
|
||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("source=rollup skips the coverage fallback on short windows (v1.4.39.2)", async () => {
|
||
// A 5-day window with 2 rollup rows is plausibly genuine sparsity
|
||
// for a user who measures BP every other day. The coverage
|
||
// fallback gates on `windowDays >= 7` so the probe + inline fold
|
||
// never fire on these short windows.
|
||
const sparseRollup = [
|
||
{
|
||
type: "BLOOD_PRESSURE_SYS",
|
||
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
|
||
mean: 128,
|
||
count: 1,
|
||
sumValue: null,
|
||
source: "APPLE_HEALTH",
|
||
},
|
||
{
|
||
type: "BLOOD_PRESSURE_SYS",
|
||
bucketStart: new Date(Date.UTC(2026, 4, 3, 0, 0, 0)),
|
||
mean: 132,
|
||
count: 1,
|
||
sumValue: null,
|
||
source: "APPLE_HEALTH",
|
||
},
|
||
];
|
||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||
sparseRollup as never,
|
||
);
|
||
|
||
const res = await GET(
|
||
getRequest(
|
||
"type=BLOOD_PRESSURE_SYS&from=2026-05-01T00:00:00Z&to=2026-05-06T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||
),
|
||
);
|
||
expect(res.status).toBe(200);
|
||
expect(recomputeUserRollups).not.toHaveBeenCalled();
|
||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||
const json = (await res.json()) as {
|
||
data: { measurements: Array<unknown> };
|
||
};
|
||
expect(json.data.measurements.length).toBe(2);
|
||
});
|
||
|
||
it("source=rollup keeps the sparse rollup when live `measurements` agrees (v1.4.39.2)", async () => {
|
||
// Genuine sparsity: the user measured BP twice in 30 days. The
|
||
// probe finds the same 2 distinct days in `measurements` so the
|
||
// route returns the 2 rollup rows without firing the inline
|
||
// fold. The chart's `< 3 daily points` empty-state is the right
|
||
// surface for this case — the data really is sparse.
|
||
const sparseRollup = [
|
||
{
|
||
type: "BLOOD_PRESSURE_SYS",
|
||
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
|
||
mean: 128,
|
||
count: 1,
|
||
sumValue: null,
|
||
source: "APPLE_HEALTH",
|
||
},
|
||
{
|
||
type: "BLOOD_PRESSURE_SYS",
|
||
bucketStart: new Date(Date.UTC(2026, 4, 10, 0, 0, 0)),
|
||
mean: 132,
|
||
count: 1,
|
||
sumValue: null,
|
||
source: "APPLE_HEALTH",
|
||
},
|
||
];
|
||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||
sparseRollup as never,
|
||
);
|
||
// Probe agrees — 2 distinct days in `measurements` too.
|
||
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
|
||
{ days: BigInt(2) },
|
||
] as never);
|
||
|
||
const res = await GET(
|
||
getRequest(
|
||
"type=BLOOD_PRESSURE_SYS&from=2026-04-30T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||
),
|
||
);
|
||
expect(res.status).toBe(200);
|
||
// Probe fired (suspicious-low rollup) but the inline fold did
|
||
// not (live and rollup agree).
|
||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||
expect(recomputeUserRollups).not.toHaveBeenCalled();
|
||
const json = (await res.json()) as {
|
||
data: { measurements: Array<unknown> };
|
||
};
|
||
expect(json.data.measurements.length).toBe(2);
|
||
});
|
||
|
||
it("returns a weekly series when the all-time window is under two years", async () => {
|
||
// Three months past one year → falls in the weekly grain band.
|
||
const weeks = Array.from({ length: 60 }, (_, i) => ({
|
||
type: "PULSE",
|
||
bucket_start: new Date(Date.UTC(2025, 0, 6 + i * 7, 0, 0, 0)),
|
||
avg: 60 + (i % 5),
|
||
cnt: 7 * 24 * 60,
|
||
}));
|
||
vi.mocked(prisma.$queryRaw).mockResolvedValue(weeks as never);
|
||
|
||
const res = await GET(
|
||
getRequest(
|
||
"type=PULSE&from=2024-12-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=weekly&limit=5000",
|
||
),
|
||
);
|
||
expect(res.status).toBe(200);
|
||
const json = (await res.json()) as {
|
||
data: { meta: { aggregate?: string }; measurements: Array<unknown> };
|
||
};
|
||
expect(json.data.meta.aggregate).toBe("weekly");
|
||
expect(json.data.measurements.length).toBe(60);
|
||
});
|
||
});
|