mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items into a single release. Headline: server-side suppression of MEDICATION_REMINDER APNs for iOS clients that own their local reminders (closes GH #206), a push_attempts table backing /api/admin/notifications/diagnostic, and a sweep of v1.4.48 QA forward-findings. Performance (the user-visible win): - /api/analytics cold-fallback path splits the heavy single-statement aggregate into two parallel queries. The linearly composable columns (count / min / max / mean) keep the full-partition scan; the windowed 7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so the planner does an index range scan on (user_id, type, measured_at) instead of a full-partition sequential scan. Output is bit-identical; cold slim slice drops from multi-second to sub-second on tenants with large measurement partitions. Server-side notifications: - GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics. Initial category medication.clientManaged: boolean (default false). Cron medicationReminderJob skips dose-due APNs when the flag is true, emitting a medication_reminder.suppressed_client_managed wide-event annotation per skip. - push_attempts table backs the diagnostic endpoint. Every APNS / Web-Push / Telegram / NTFY sender writes a fire-and-forget row. Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days. - /api/admin/notifications/diagnostic — admin-only endpoint surfacing device APNs token presence (masked), channel config presence, and recent push attempts. Registered in OpenAPI. - Per-channel APNs "Send test" button now fires with eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive + priority-10 branch runs. A successful self-test now exercises the exact path production reminders take; an unsuccessful one points the user at iOS Settings → Time Sensitive Notifications rather than appearing to be a server-side bug. Withings reliability: - OAuth callback consumes the nonce atomically (delete-first with P2025 catch for the replay branch). The previous read-then-delete pattern allowed a race between two concurrent callbacks. - ?reason=state redirect differentiated into csrf1 / replay / expired / cross_user so operators can tell mismatch types apart without DB shell access. - connect endpoint rate-limited to 10/60s/user; row-create failures redirect to a consistent error surface. Observability hardening: - redactSensitiveFields(body) denylist helper covers password / token / secret / apiKey / authorization / csrfState / nonce, recursive, case-insensitive. Caught: /api/devices invalid_format issues were echoing apnsToken values verbatim into the audit log. - sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload applied to 14 audit-log sites. Migrations: 0079 (notification_prefs), 0080 (push_attempts). Tests: 4815 → 5272 unit + integration. Workout-batch integration suite that had been red on main since v1.4.45 (three release commits shipped through it) now green again — updated to match v1.4.42 cross-source dedup semantics. Resolves PR #207.
454 lines
17 KiB
TypeScript
454 lines
17 KiB
TypeScript
/**
|
||
* v1.4.33 P0 regression coverage for `GET /api/analytics`.
|
||
*
|
||
* Production stacktrace 2026-05-16 14:39:51 UTC (cf-ray 9fcb223c…):
|
||
* `RangeError: Maximum call stack size exceeded`
|
||
* at Promise.all (index 3 — `PULSE`)
|
||
*
|
||
* Root cause was in `summarize()` (`src/lib/analytics/trends.ts`) — the
|
||
* `Math.min(...values)` / `Math.max(...values)` spread blew V8's
|
||
* ~125 000-arg function-arity ceiling once an Apple-Health-synced PULSE
|
||
* series for a multi-year power user grew past it.
|
||
*
|
||
* The fix folds min/max into the single sum/mean pass; this test pins
|
||
* the contract from the route entry-point so a future refactor (e.g.
|
||
* the v1.4.33 C1 SQL-side aggregation rewrite) can't silently
|
||
* reintroduce a spread anywhere along the call chain.
|
||
*/
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
vi.mock("@/lib/db", () => ({
|
||
prisma: {
|
||
// v1.4.37 W2 — `ensureUserRollupsFresh` pokes `measurement.findFirst`
|
||
// for the newest-measurement watermark; mock both shapes.
|
||
measurement: {
|
||
findMany: vi.fn(),
|
||
findFirst: vi.fn().mockResolvedValue(null),
|
||
},
|
||
moodEntry: { findMany: vi.fn() },
|
||
medicationIntakeEvent: { findMany: vi.fn() },
|
||
medication: { findMany: vi.fn() },
|
||
// v1.4.33 C1 — slim summaries slice runs through `$queryRaw`. The
|
||
// v1.4.36 per-type coverage probe and the v1.4.37 default-slice
|
||
// probe also ride `$queryRaw`. Default to an empty coverage map so
|
||
// the route falls back to the live aggregator branches and the
|
||
// assertions stay byte-shape stable.
|
||
$queryRaw: vi.fn().mockResolvedValue([]),
|
||
$queryRawUnsafe: vi.fn().mockResolvedValue([]),
|
||
// v1.4.35 — slim slice also reads DAY buckets; the freshness
|
||
// watermark inside `ensureUserRollupsFresh` pokes `findFirst`.
|
||
// Default both to empty so the parity check falls back to live
|
||
// SQL and the slim slice assertions stay byte-shape stable.
|
||
measurementRollup: {
|
||
findMany: vi.fn().mockResolvedValue([]),
|
||
findFirst: vi.fn().mockResolvedValue(null),
|
||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||
upsert: vi.fn().mockResolvedValue({}),
|
||
},
|
||
$transaction: vi.fn().mockImplementation(async (queries: unknown[]) => {
|
||
if (Array.isArray(queries)) return Promise.all(queries);
|
||
return undefined;
|
||
}),
|
||
},
|
||
}));
|
||
|
||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||
|
||
vi.mock("@/lib/auth/audit", () => ({
|
||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||
}));
|
||
|
||
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 { ANALYTICS_TYPE_FETCH_CONCURRENCY, GET } from "../route";
|
||
import { prisma } from "@/lib/db";
|
||
import { getSession } from "@/lib/auth/session";
|
||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||
|
||
interface MeasurementRow {
|
||
id: string;
|
||
measuredAt: Date;
|
||
value: number;
|
||
source: "MANUAL" | "WITHINGS" | "IMPORT" | "APPLE_HEALTH";
|
||
deviceType: string | null;
|
||
sleepStage?: string | null;
|
||
}
|
||
|
||
const SESSION_USER = {
|
||
id: "user-1",
|
||
username: "test",
|
||
role: "USER" as const,
|
||
timezone: "Europe/Berlin",
|
||
heightCm: 180,
|
||
dateOfBirth: new Date("1980-01-01T00:00:00Z"),
|
||
sourcePriorityJson: null,
|
||
};
|
||
|
||
const SESSION_OK = {
|
||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||
user: SESSION_USER as never,
|
||
};
|
||
|
||
function pulseRow(measuredAt: Date, value: number, id: string): MeasurementRow {
|
||
return {
|
||
id,
|
||
measuredAt,
|
||
value,
|
||
source: "APPLE_HEALTH",
|
||
deviceType: "watch",
|
||
};
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.resetAllMocks();
|
||
// v1.4.34 IW-G — reset the analytics LRU between tests so each case
|
||
// observes a cold cache (otherwise tests sharing a userId would land
|
||
// on the prior test's cached response).
|
||
__resetAllCachesForTests();
|
||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||
[] as never,
|
||
);
|
||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||
// v1.4.35 — rollup table defaults. `resetAllMocks` clears the
|
||
// module-level implementations, so we re-seed both per test. Empty
|
||
// findMany + null findFirst means the slim slice's parity check
|
||
// diverges and falls back to live SQL (preserves the pre-v1.4.35
|
||
// assertions in this file).
|
||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||
[] as never,
|
||
);
|
||
vi.mocked(prisma.measurementRollup.findFirst).mockResolvedValue(
|
||
null as never,
|
||
);
|
||
vi.mocked(prisma.measurementRollup.deleteMany).mockResolvedValue(
|
||
{ count: 0 } as never,
|
||
);
|
||
vi.mocked(prisma.measurementRollup.upsert).mockResolvedValue(
|
||
{} as never,
|
||
);
|
||
// v1.4.37 W2 — `ensureUserRollupsFresh` reads `measurement.findFirst`;
|
||
// the per-type coverage probe + the rollup-recompute aggregator ride
|
||
// `$queryRaw` / `$queryRawUnsafe`. Default to empty so the route
|
||
// falls back to the live fast-path branches and the assertions stay
|
||
// byte-shape stable.
|
||
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null as never);
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
vi.mocked(prisma.$queryRaw as any).mockResolvedValue([]);
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
vi.mocked(prisma.$queryRawUnsafe as any).mockResolvedValue([]);
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
vi.mocked(prisma.$transaction as any).mockImplementation(
|
||
async (queries: unknown) => {
|
||
if (Array.isArray(queries)) return Promise.all(queries);
|
||
return undefined;
|
||
},
|
||
);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe("GET /api/analytics", () => {
|
||
it("survives a 130 000-row PULSE series without blowing the stack", async () => {
|
||
// The chunked reader pages 5 000 at a time. Simulate one full page
|
||
// for PULSE so the route's per-type aggregator hits the failing
|
||
// `summarize()` codepath with a 5 000-point series, then stop. We
|
||
// intentionally don't seed every single Apple Watch sample in the
|
||
// mock — V8's spread-arg ceiling is repeatable with a far smaller
|
||
// array than production (see `trends.test.ts` for the 250 000-row
|
||
// direct regression). This test pins the route-entry contract.
|
||
const N = 5_000;
|
||
const now = Date.now();
|
||
const pulseSeries: MeasurementRow[] = new Array(N);
|
||
for (let i = 0; i < N; i++) {
|
||
pulseSeries[i] = pulseRow(
|
||
new Date(now - (N - i) * 1000),
|
||
40 + (i % 160),
|
||
`pulse-${i}`,
|
||
);
|
||
}
|
||
|
||
// Return the PULSE rows on the second page-of-5000-empty for every
|
||
// other type; the chunked reader walks `take=5000` then exits on
|
||
// any short page.
|
||
vi.mocked(prisma.measurement.findMany).mockImplementation(
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
(async (args: any) => {
|
||
if (args.where?.type === "PULSE" && !args.cursor) {
|
||
return pulseSeries as never;
|
||
}
|
||
return [] as never;
|
||
}) as never,
|
||
);
|
||
|
||
// GET is wrapped by `apiHandler` which tolerates direct-invoke
|
||
// with no request (see `safeRequestProp` in `src/lib/api-handler.ts`).
|
||
// Cast through `unknown` so the TS signature `(...args: never[])`
|
||
// doesn't fight the vitest direct-invoke pattern.
|
||
const res = await (
|
||
GET as unknown as (...args: never[]) => Promise<Response>
|
||
)();
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as {
|
||
data: { summaries: Record<string, { count: number; min: number | null; max: number | null }> };
|
||
};
|
||
expect(body.data.summaries.PULSE.count).toBe(N);
|
||
// 40 + (N-1) % 160 → wave around (40..199).
|
||
expect(body.data.summaries.PULSE.min).toBeGreaterThanOrEqual(40);
|
||
expect(body.data.summaries.PULSE.max).toBeLessThanOrEqual(199);
|
||
});
|
||
|
||
it("returns a 200 envelope for a brand-new user with zero rows", async () => {
|
||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||
|
||
const res = await (
|
||
GET as unknown as (...args: never[]) => Promise<Response>
|
||
)();
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as {
|
||
data: {
|
||
summaries: Record<string, { count: number }>;
|
||
bmi: number | null;
|
||
healthScore: unknown;
|
||
};
|
||
};
|
||
// Empty series should not crash on the new fold path either.
|
||
expect(body.data.summaries.PULSE.count).toBe(0);
|
||
expect(body.data.summaries.WEIGHT.count).toBe(0);
|
||
expect(body.data.bmi).toBeNull();
|
||
expect(body.data.healthScore).toBeNull();
|
||
});
|
||
|
||
// v1.4.34 IW-B — the dashboard tile strip reads `lastSeenByType[type]?.daysAgo`
|
||
// and forwards it to each `<TrendCard>` so a metric the user hasn't
|
||
// logged in a while keeps its tile visible with an "Letzter Wert vor
|
||
// Xd" caption instead of disappearing.
|
||
it("emits lastSeenByType keyed on the freshest measuredAt per type", async () => {
|
||
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
|
||
const oneDayAgo = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000);
|
||
|
||
vi.mocked(prisma.measurement.findMany).mockImplementation(
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
(async (args: any) => {
|
||
if (args.where?.type === "WEIGHT" && !args.cursor) {
|
||
return [
|
||
{
|
||
id: "w-old",
|
||
measuredAt: eightDaysAgo,
|
||
value: 80.5,
|
||
source: "MANUAL",
|
||
deviceType: null,
|
||
},
|
||
] as never;
|
||
}
|
||
if (args.where?.type === "PULSE" && !args.cursor) {
|
||
return [
|
||
{
|
||
id: "p-fresh",
|
||
measuredAt: oneDayAgo,
|
||
value: 72,
|
||
source: "APPLE_HEALTH",
|
||
deviceType: "watch",
|
||
},
|
||
] as never;
|
||
}
|
||
return [] as never;
|
||
}) as never,
|
||
);
|
||
|
||
const res = await (
|
||
GET as unknown as (...args: never[]) => Promise<Response>
|
||
)();
|
||
expect(res.status).toBe(200);
|
||
// bfcache-friendly Cache-Control — verifies the IW-B header
|
||
// posture is on the wire.
|
||
expect(res.headers.get("Cache-Control")).toBe(
|
||
"private, max-age=0, must-revalidate",
|
||
);
|
||
|
||
const body = (await res.json()) as {
|
||
data: {
|
||
lastSeenByType: Record<
|
||
string,
|
||
{ lastSeenAt: string; daysAgo: number } | null
|
||
>;
|
||
};
|
||
};
|
||
expect(body.data.lastSeenByType.WEIGHT?.daysAgo).toBeGreaterThanOrEqual(7);
|
||
expect(body.data.lastSeenByType.WEIGHT?.daysAgo).toBeLessThanOrEqual(9);
|
||
expect(body.data.lastSeenByType.PULSE?.daysAgo).toBeGreaterThanOrEqual(0);
|
||
expect(body.data.lastSeenByType.PULSE?.daysAgo).toBeLessThanOrEqual(2);
|
||
// Types the user never logged report `null` so the tile-strip
|
||
// helper falls through without painting a caption.
|
||
expect(body.data.lastSeenByType.BLOOD_GLUCOSE).toBeNull();
|
||
});
|
||
|
||
// v1.4.33 C1 — slim summaries slice. The route branches on
|
||
// `?slice=summaries` BEFORE any chunked findMany; the two `$queryRaw`
|
||
// passes carry the per-type DataSummary shape with the same
|
||
// contract the dashboard tile strip reads.
|
||
it("returns the slim summaries slice when ?slice=summaries is set", async () => {
|
||
const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
|
||
// v1.4.36 — slim slice opens with a cheap `SELECT COUNT(*) FROM
|
||
// measurement_rollups` probe; `n: 0` forces the cold-fallback
|
||
// path. v1.4.48 M0 split that path's single aggregate query into
|
||
// two parallel ones (all-time + windowed), so the cold fixture
|
||
// now mocks 4 `$queryRaw` calls: coverage + allTime + windowed +
|
||
// latest.
|
||
vi.mocked(prisma.$queryRaw)
|
||
.mockResolvedValueOnce([{ n: BigInt(0) }] as never)
|
||
.mockResolvedValueOnce([
|
||
{
|
||
type: "WEIGHT",
|
||
count: BigInt(12),
|
||
min_value: 80.0,
|
||
max_value: 84.5,
|
||
mean_value: 82.1,
|
||
},
|
||
] as never)
|
||
.mockResolvedValueOnce([
|
||
{
|
||
type: "WEIGHT",
|
||
avg7: 82.0,
|
||
avg30: 82.2,
|
||
slope7: -0.05,
|
||
r2_7: 0.4,
|
||
slope30: -0.02,
|
||
r2_30: 0.3,
|
||
slope90: -0.01,
|
||
r2_90: 0.2,
|
||
},
|
||
] as never)
|
||
.mockResolvedValueOnce([
|
||
{ type: "WEIGHT", value: 81.8, measured_at: fiveDaysAgo },
|
||
] as never);
|
||
|
||
const req = new Request(
|
||
"http://localhost/api/analytics?slice=summaries",
|
||
);
|
||
const res = await (
|
||
GET as unknown as (req: Request) => Promise<Response>
|
||
)(req);
|
||
expect(res.status).toBe(200);
|
||
// v1.4.34 IW-B — bfcache-friendly directive rides on the slim
|
||
// slice too.
|
||
expect(res.headers.get("Cache-Control")).toBe(
|
||
"private, max-age=0, must-revalidate",
|
||
);
|
||
const body = (await res.json()) as {
|
||
data: {
|
||
summaries: Record<
|
||
string,
|
||
{
|
||
count: number;
|
||
latest: number | null;
|
||
slope30: { slope: number; direction: string } | null;
|
||
}
|
||
>;
|
||
bmi: number | null;
|
||
lastSeenByType: Record<
|
||
string,
|
||
{ lastSeenAt: string; daysAgo: number } | null
|
||
>;
|
||
};
|
||
};
|
||
// The slim slice produced WEIGHT from the SQL pass; no chunked
|
||
// findMany was called.
|
||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||
expect(body.data.summaries.WEIGHT.count).toBe(12);
|
||
expect(body.data.summaries.WEIGHT.latest).toBe(81.8);
|
||
expect(body.data.summaries.WEIGHT.slope30?.direction).toBe("down");
|
||
// Slim slice never carries BMI — the consumer re-derives.
|
||
expect(body.data.bmi).toBeNull();
|
||
// v1.4.34 IW-B — slim slice surfaces lastSeenByType too so the
|
||
// tile-strip caption works regardless of which slice the client
|
||
// read.
|
||
expect(body.data.lastSeenByType.WEIGHT?.daysAgo).toBeGreaterThanOrEqual(4);
|
||
expect(body.data.lastSeenByType.WEIGHT?.daysAgo).toBeLessThanOrEqual(6);
|
||
expect(body.data.lastSeenByType.PULSE).toBeNull();
|
||
});
|
||
|
||
// v1.4.40 W-POOL — pin the bounded-concurrency cap on the per-type
|
||
// fan-out inside `buildAnalyticsResponse`. The v1.4.39 empirical
|
||
// trace (`.planning/round-v1439-empirical-trace.md` §B1) showed the
|
||
// raw `Promise.all` over `fetchMeasurementSeriesChunked` holding
|
||
// ≥8 of the default-10 pg.Pool slots for 6.5 s on a power-user cold
|
||
// mount. The p-limit(4) wrapper keeps the same total work but caps
|
||
// inflight Prisma round-trips for this branch at 4.
|
||
//
|
||
// The assertion instruments the `prisma.measurement.findMany` mock
|
||
// to track peak inflight count + total batches. With N≥15 types and
|
||
// a 20 ms per-call delay, an unbounded `Promise.all` would peak at
|
||
// ~15 and finish in ~20 ms; the bounded version peaks at exactly 4
|
||
// and finishes in ~80 ms (4 batches × 20 ms ceiling).
|
||
it("caps per-type Prisma fan-out at ANALYTICS_TYPE_FETCH_CONCURRENCY", async () => {
|
||
// Default-slice path requires no coverage so the route falls into
|
||
// the live per-type loop where the fan-out lives.
|
||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||
|
||
let inflight = 0;
|
||
let peak = 0;
|
||
let calls = 0;
|
||
|
||
vi.mocked(prisma.measurement.findMany).mockImplementation(
|
||
(async () => {
|
||
// Only count the per-type chunked fan-out calls (not the
|
||
// narrower glucose / sleep-stage `findMany` reads inside the
|
||
// same route; those run sequentially AFTER the fan-out
|
||
// completes so they never overlap with peak inflight).
|
||
calls += 1;
|
||
inflight += 1;
|
||
peak = Math.max(peak, inflight);
|
||
// 20 ms is large enough to make the bounded vs unbounded
|
||
// distinction observable but small enough to keep the suite
|
||
// under a second.
|
||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||
inflight -= 1;
|
||
// Return an empty page so the chunked reader exits after one
|
||
// round-trip per type.
|
||
return [] as never;
|
||
}) as never,
|
||
);
|
||
|
||
const t0 = Date.now();
|
||
const res = await (
|
||
GET as unknown as (...args: never[]) => Promise<Response>
|
||
)();
|
||
const elapsed = Date.now() - t0;
|
||
expect(res.status).toBe(200);
|
||
|
||
// The cap itself — peak inflight Prisma reads against
|
||
// `measurements` never crosses the configured ceiling.
|
||
expect(ANALYTICS_TYPE_FETCH_CONCURRENCY).toBe(4);
|
||
expect(peak).toBeLessThanOrEqual(ANALYTICS_TYPE_FETCH_CONCURRENCY);
|
||
|
||
// Sanity: more than one type was actually walked, otherwise the
|
||
// bound is unfalsifiable.
|
||
expect(calls).toBeGreaterThanOrEqual(8);
|
||
|
||
// Wall-clock proves the lanes are saturated rather than serialised
|
||
// by accident. 4 lanes × 20 ms × ⌈calls/4⌉ ≈ 60-100 ms; an
|
||
// unbounded fan-out would finish in ~25-40 ms; a fully serial
|
||
// walk would take calls × 20 ms ≈ 300+ ms. The window catches
|
||
// both regressions.
|
||
expect(elapsed).toBeGreaterThanOrEqual(40);
|
||
expect(elapsed).toBeLessThan(calls * 20);
|
||
});
|
||
});
|