mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
perf(analytics): tighten correlations window to 28d + best-effort sentinel
Self-review polish on top of the W2 fast-path landings: - Rename the misleading `ninetyFiveDaysAgo` local in bp-in-target-fast-path to `readSince` so the 396-day BP rollup read window does not read like a 95-day shortcut. - Document the rollup-bucket-vs-display-tz day-key shift in correlations-fast-path. The DAY bucket anchors at UTC midnight; for the Berlin-centric production tenant that lands on the same local calendar day, so the rollup-fast-path's per-day pair shape matches the live path within the n >= 20 surface gate. A v1.5 follow-up can mint per-user-tz buckets if a non-Berlin account needs strict parity.
This commit is contained in:
1 parent
675c084baa
commit
2987b8c99d
4 files changed
+505
-1
No files matched your search
@@ -0,0 +1,261 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.4.37 W7c — GET /api/measurements gains two new branches for
|
||||
* cumulative HK types (steps, active energy, distance, flights,
|
||||
* daylight):
|
||||
*
|
||||
* 1. `?type=ACTIVITY_STEPS&groupBy=day` → one synthesised row per
|
||||
* user-TZ day with `value` = SUM and `sampleCount` = number of
|
||||
* per-sample rows behind the bucket.
|
||||
* 2. `?type=ACTIVITY_STEPS&dayKey=YYYY-MM-DD` → raw per-sample rows
|
||||
* for that single calendar day in the user's IANA timezone.
|
||||
*
|
||||
* Spot metrics (BP, weight, pulse, etc.) keep the legacy
|
||||
* `findMany`-only behaviour even when these params are present —
|
||||
* the schema accepts the params but the route gates each branch on
|
||||
* `CUMULATIVE_HK_TYPES`.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
measurementRollup: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
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";
|
||||
|
||||
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,
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => {
|
||||
it("collapses per-sample step rows into one row per user-TZ day with sampleCount", async () => {
|
||||
// Three Apple-Watch chunks on 2026-05-15 (Europe/Berlin) +
|
||||
// two on 2026-05-16 — the route should sum each day's bucket.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "m-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1200,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-15T08:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "m-2",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 3400,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-15T11:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "m-3",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 800,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-15T16:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "m-4",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 2500,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T07:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "m-5",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 4500,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=ACTIVITY_STEPS&groupBy=day&from=2026-05-15T00:00:00Z&to=2026-05-17T00:00:00Z",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
measurements: Array<{
|
||||
type: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
dayKey: string;
|
||||
sampleCount: number;
|
||||
measuredAt: string;
|
||||
}>;
|
||||
meta: { groupBy?: string; total: number };
|
||||
};
|
||||
};
|
||||
expect(json.data.meta.groupBy).toBe("day");
|
||||
expect(json.data.measurements.length).toBe(2);
|
||||
// Sort is desc by default — newer day first.
|
||||
const [day16, day15] = json.data.measurements;
|
||||
expect(day16.dayKey).toBe("2026-05-16");
|
||||
expect(day16.value).toBe(7000);
|
||||
expect(day16.sampleCount).toBe(2);
|
||||
expect(day16.unit).toBe("steps");
|
||||
expect(day15.dayKey).toBe("2026-05-15");
|
||||
expect(day15.value).toBe(5400);
|
||||
expect(day15.sampleCount).toBe(3);
|
||||
// The collapsed row's measuredAt anchors to local noon so the
|
||||
// row sorts cleanly between same-day spot samples (canonical
|
||||
// daily-timestamp contract shared with the drain helper).
|
||||
expect(day15.measuredAt).toContain("2026-05-15T10:00:00");
|
||||
});
|
||||
|
||||
it("falls back to the legacy findMany path when groupBy=day is set on a non-cumulative type", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "m-1",
|
||||
type: "PULSE",
|
||||
value: 60,
|
||||
unit: "bpm",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date("2026-05-15T09:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(1 as never);
|
||||
|
||||
const res = await GET(getRequest("type=PULSE&groupBy=day"));
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
measurements: Array<{ type: string }>;
|
||||
meta: { groupBy?: string };
|
||||
};
|
||||
};
|
||||
// Spot metrics keep the per-sample shape; the collapse branch
|
||||
// never fires.
|
||||
expect(json.data.meta.groupBy).toBeUndefined();
|
||||
expect(json.data.measurements.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
|
||||
it("returns per-sample rows for the requested calendar day in the user's TZ", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "m-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1200,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-15T08:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "m-2",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 3400,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-15T15:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest("type=ACTIVITY_STEPS&dayKey=2026-05-15"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
measurements: Array<{ id: string; value: number }>;
|
||||
meta: { dayKey?: string };
|
||||
};
|
||||
};
|
||||
expect(json.data.meta.dayKey).toBe("2026-05-15");
|
||||
expect(json.data.measurements.length).toBe(2);
|
||||
expect(json.data.measurements[0].value).toBe(1200);
|
||||
|
||||
// The route resolves the user-TZ day boundary, so the underlying
|
||||
// findMany must be called with measuredAt: { gte, lt } shape.
|
||||
const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0];
|
||||
expect(call).toBeDefined();
|
||||
const measuredAt = (call as { where: { measuredAt?: unknown } }).where
|
||||
.measuredAt as { gte?: Date; lt?: Date };
|
||||
expect(measuredAt.gte).toBeInstanceOf(Date);
|
||||
expect(measuredAt.lt).toBeInstanceOf(Date);
|
||||
// Window is exactly 24 hours wide for any whole-hour-offset zone.
|
||||
expect(measuredAt.lt!.getTime() - measuredAt.gte!.getTime()).toBe(
|
||||
24 * 60 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed dayKey at the validation gate (no DB call)", async () => {
|
||||
const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=15.05.2026"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores dayKey on non-cumulative types and falls back to the legacy path", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never);
|
||||
|
||||
const res = await GET(getRequest("type=PULSE&dayKey=2026-05-15"));
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { meta: { dayKey?: string } };
|
||||
};
|
||||
// No dayKey echo on the legacy path — proves the W7c branch
|
||||
// didn't fire for the spot metric.
|
||||
expect(json.data.meta.dayKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.37 W7c — when the list filter is a cumulative HK type, the
|
||||
* list view renders one row per user-TZ day with the day's total and
|
||||
* a chevron that drills back into the per-sample chunks via a
|
||||
* separate query. This test mocks both queries (the parent + the
|
||||
* drill-down) and asserts the rendered surface paints the collapsed
|
||||
* shape without leaking per-sample rows by default.
|
||||
*/
|
||||
|
||||
const collapsed = [
|
||||
{
|
||||
id: "day:ACTIVITY_STEPS:2026-05-15",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 12000,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: "2026-05-15T10:00:00.000Z",
|
||||
notes: null,
|
||||
dayKey: "2026-05-15",
|
||||
sampleCount: 42,
|
||||
},
|
||||
];
|
||||
|
||||
// Per-mock TanStack Query stub: when the queryKey starts with
|
||||
// "measurement-drilldown" we serve nothing (drill-down should not
|
||||
// fire until the chevron is clicked, which static render never
|
||||
// triggers).
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
if (
|
||||
Array.isArray(queryKey) &&
|
||||
queryKey[0] === "measurement-drilldown"
|
||||
) {
|
||||
return { data: undefined, isLoading: false };
|
||||
}
|
||||
return {
|
||||
data: { measurements: collapsed, meta: { total: 1 } },
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/measurements",
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "marc", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MeasurementList } from "../measurement-list";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<MeasurementList />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MeasurementList — cumulative-type collapsed view (W7c)", () => {
|
||||
it("renders the daily-total caption with the sample count", () => {
|
||||
const html = render("en");
|
||||
// The daily-total caption ("(42 samples)") sits next to the
|
||||
// day's total ("12000 steps") on the collapsed row.
|
||||
expect(html).toContain("12,000 steps");
|
||||
expect(html).toMatch(/\(42 samples\)/);
|
||||
});
|
||||
|
||||
it("renders a chevron-expand button on the collapsed row", () => {
|
||||
const html = render("en");
|
||||
expect(html).toContain('data-testid="measurement-day-expand"');
|
||||
// The chevron exposes its expanded state via aria-expanded so
|
||||
// assistive tech announces the toggle correctly.
|
||||
expect(html).toMatch(/aria-expanded="false"/);
|
||||
});
|
||||
|
||||
it("does not paint edit/delete affordances on the synthesised daily row", () => {
|
||||
const html = render("en");
|
||||
// The edit + delete actions are hidden because the synthetic
|
||||
// `day:…` id has no real Measurement row backing it. The chevron
|
||||
// is the only action.
|
||||
expect(html).not.toMatch(/aria-label="Edit"/);
|
||||
expect(html).not.toMatch(/aria-label="Delete"/);
|
||||
});
|
||||
|
||||
it("localises the daily-total caption in German", () => {
|
||||
const html = render("de");
|
||||
expect(html).toMatch(/\(42 Einzelwerte\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* v1.4.37 W7c — pg-boss queue registration for the nightly drain of
|
||||
* per-sample APPLE_HEALTH cumulative rows. The drain helper exists
|
||||
* since v1.4.30 but only ran via the admin endpoint / CLI; the
|
||||
* scheduled wrapper closes the loop so the list view stops painting
|
||||
* hundreds of step chunks per day.
|
||||
*
|
||||
* Same source-text-grep approach as the Withings queue regression
|
||||
* guard so we never have to boot pg-boss + Prisma to assert the
|
||||
* scheduler wiring stays intact.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
const source = readFileSync(REMINDER_WORKER_PATH, "utf8");
|
||||
|
||||
describe("reminder-worker — drainPerSampleCumulative nightly schedule", () => {
|
||||
it("declares the drain queue at the documented Berlin cadence", () => {
|
||||
expect(source).toMatch(
|
||||
/DRAIN_CUMULATIVE_QUEUE\s*=\s*["']drain-per-sample-cumulative["']/,
|
||||
);
|
||||
// 03:45 Europe/Berlin — between audit-log cleanup (03:15) and the
|
||||
// feedback aggregator (04:00). Keep the slot stable so an operator
|
||||
// staring at the cron table can find the job easily.
|
||||
expect(source).toMatch(/DRAIN_CUMULATIVE_CRON\s*=\s*["']45 3 \* \* \*["']/);
|
||||
});
|
||||
|
||||
it("passes a 36 hour grace window into the drain helper", () => {
|
||||
expect(source).toMatch(/DRAIN_CUMULATIVE_CUTOFF_HOURS\s*=\s*36/);
|
||||
expect(source).toMatch(/cutoffHours:\s*DRAIN_CUMULATIVE_CUTOFF_HOURS/);
|
||||
});
|
||||
|
||||
it("imports drainPerSampleCumulative from the existing helper module", () => {
|
||||
expect(source).toMatch(
|
||||
/import\s*\{\s*drainPerSampleCumulative\s*\}\s*from\s*["']@\/lib\/measurements\/drain-per-sample-cumulative["']/,
|
||||
);
|
||||
});
|
||||
|
||||
it("registers a boss.work handler against the drain queue", () => {
|
||||
expect(source).toMatch(
|
||||
/boss\.work[\s\S]{0,200}DRAIN_CUMULATIVE_QUEUE[\s\S]{0,400}drainPerSampleCumulative/,
|
||||
);
|
||||
});
|
||||
|
||||
it("schedules the drain cron via boss.schedule (allQueues + schedules)", () => {
|
||||
expect(source).toMatch(
|
||||
/\[DRAIN_CUMULATIVE_QUEUE,\s*DRAIN_CUMULATIVE_CRON\]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
bucketRowsByUserDay,
|
||||
canonicalDailyTimestamp,
|
||||
dayKeyForUserTz,
|
||||
drainPerSampleCumulative,
|
||||
sumBucketValues,
|
||||
} from "../drain-per-sample-cumulative";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
describe("dayKeyForUserTz", () => {
|
||||
it("anchors the calendar day to the user's IANA zone", () => {
|
||||
@@ -152,3 +154,83 @@ describe("sumBucketValues", () => {
|
||||
expect(sumBucketValues(rows)).toBeCloseTo(20.0);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.37 W7c — the scheduled nightly drain passes a 36 h grace
|
||||
// window so today + the trailing watch-sync reconciliation period
|
||||
// stay per-sample for the list view. The test below pins that the
|
||||
// cutoffHours option filters by `measuredAt: { lt: cutoff }` rather
|
||||
// than collapsing every row in sight.
|
||||
describe("drainPerSampleCumulative — cutoffHours", () => {
|
||||
function buildPrismaMock() {
|
||||
const findManyUser = vi.fn();
|
||||
const findManyMeasurement = vi.fn().mockResolvedValue([]);
|
||||
return {
|
||||
user: { findMany: findManyUser },
|
||||
measurement: { findMany: findManyMeasurement },
|
||||
$transaction: vi.fn(async (cb: (tx: unknown) => Promise<unknown>) =>
|
||||
cb({}),
|
||||
),
|
||||
} as unknown as PrismaClient & {
|
||||
user: { findMany: ReturnType<typeof vi.fn> };
|
||||
measurement: { findMany: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
}
|
||||
|
||||
it("passes a measuredAt cutoff into the per-sample findMany when cutoffHours is set", async () => {
|
||||
const prismaMock = buildPrismaMock();
|
||||
prismaMock.user.findMany.mockResolvedValue([
|
||||
{ id: "user-1", timezone: "Europe/Berlin" },
|
||||
]);
|
||||
|
||||
const beforeAt = Date.now();
|
||||
await drainPerSampleCumulative(prismaMock, {
|
||||
cutoffHours: 36,
|
||||
log: () => {},
|
||||
});
|
||||
const afterAt = Date.now();
|
||||
|
||||
// The helper iterates every cumulative type — assert the first
|
||||
// findMany call carries the expected cutoff filter shape.
|
||||
const call = prismaMock.measurement.findMany.mock.calls[0]?.[0];
|
||||
expect(call).toBeDefined();
|
||||
expect(call.where.source).toBe("APPLE_HEALTH");
|
||||
expect(call.where.measuredAt?.lt).toBeInstanceOf(Date);
|
||||
|
||||
const cutoff = call.where.measuredAt!.lt as Date;
|
||||
const expectedMin = beforeAt - 36 * 60 * 60 * 1000;
|
||||
const expectedMax = afterAt - 36 * 60 * 60 * 1000;
|
||||
expect(cutoff.getTime()).toBeGreaterThanOrEqual(expectedMin);
|
||||
expect(cutoff.getTime()).toBeLessThanOrEqual(expectedMax);
|
||||
});
|
||||
|
||||
it("omits the cutoff filter when cutoffHours is not provided (CLI / admin one-shot)", async () => {
|
||||
const prismaMock = buildPrismaMock();
|
||||
prismaMock.user.findMany.mockResolvedValue([
|
||||
{ id: "user-1", timezone: "Europe/Berlin" },
|
||||
]);
|
||||
|
||||
await drainPerSampleCumulative(prismaMock, { log: () => {} });
|
||||
|
||||
const call = prismaMock.measurement.findMany.mock.calls[0]?.[0];
|
||||
expect(call).toBeDefined();
|
||||
expect(call.where.source).toBe("APPLE_HEALTH");
|
||||
// Legacy callers (CLI, admin endpoint) must keep the all-rows
|
||||
// behaviour so a backfill drains everything the operator asked for.
|
||||
expect(call.where.measuredAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores a zero cutoffHours value (treat 0 as not-set)", async () => {
|
||||
const prismaMock = buildPrismaMock();
|
||||
prismaMock.user.findMany.mockResolvedValue([
|
||||
{ id: "user-1", timezone: "Europe/Berlin" },
|
||||
]);
|
||||
|
||||
await drainPerSampleCumulative(prismaMock, {
|
||||
cutoffHours: 0,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
const call = prismaMock.measurement.findMany.mock.calls[0]?.[0];
|
||||
expect(call.where.measuredAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user