Merge remote-tracking branch 'origin/fix/apple-health-pulse-cluster' into release/v1.32.1

This commit is contained in:
Marc-André Bombeck committed 2026-07-23 10:27:20 +02:00
commit 0375fadd85
12 files changed
+1054 -101

No files matched your search

@@ -0,0 +1,269 @@
/**
* v1.32.1 (issue #585) — wide-event observability for the go-forward
* aggregated 10-min heart-rate bucket wire contract.
*
* The intraday pulse chart reads exactly the `stats:HKQuantityTypeIdentifier
* HeartRate:<10-min-Z>` rows this batch route accepts (or rejects). A report
* of "the chart looks sparser after the ZIP-import cutoff" was previously
* undiagnosable from server logs: the baseline `measurement.batch.ingest`
* annotation carried only a total `skipped` count with no reason breakdown
* and no visibility into how many of a batch's entries specifically targeted
* an HR bucket. These tests pin the new `measurement.batch.hr-bucket`
* annotation (fires only when the batch actually carried an HR-bucket entry)
* and the `skipped_by_reason` breakdown on the baseline annotation.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
update: vi.fn(),
},
measurement: {
findMany: vi.fn(),
createManyAndReturn: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (fn as any)(prisma as unknown as { measurement: unknown });
}
}),
},
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: mocks.annotate };
});
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: async (
tx: {
measurement: {
findMany: (args: unknown) => Promise<
Array<{
id?: string;
type?: string;
source?: string;
externalId?: string | null;
}>
>;
createManyAndReturn: (args: {
data: Record<string, unknown>;
}) => Promise<Array<Record<string, unknown>>>;
updateMany: (args: {
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => Promise<unknown>;
};
},
input: Record<string, unknown> & {
userId: string;
type: string;
source: string;
externalId: string;
},
options: { exactExternalMatch?: "update" | "duplicate" } = {},
) => {
const existing = await tx.measurement.findMany({});
const exact = existing.find(
(row: { type?: string; source?: string; externalId?: string | null }) =>
row.type === input.type &&
row.source === input.source &&
row.externalId === input.externalId,
);
if (exact && options.exactExternalMatch !== "update") {
return { status: "duplicate", row: exact };
}
if (exact) {
await tx.measurement.updateMany({
where: { id: exact.id },
data: { ...input, deletedAt: null },
});
return { status: "updated", row: { ...exact, ...input } };
}
const [inserted] = await tx.measurement.createManyAndReturn({
data: input,
});
if (inserted) return { status: "inserted", row: inserted };
if (options.exactExternalMatch === "update") {
await tx.measurement.updateMany({ where: {}, data: input });
return { status: "updated", row: input };
}
return { status: "duplicate" };
},
}));
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("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
collapseToTypeDayKeys: vi.fn(() => []),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const FRESH_BUCKET_ID =
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000Z";
const REPOST_BUCKET_ID =
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T15:00:00.000Z";
const MALFORMED_BUCKET_ID =
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:35:00.000Z";
const SAMPLE_ID = "C0FFEE00-0000-4000-8000-000000000000";
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function hrEntry(externalId: string, value: number) {
return {
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value,
unit: "count/min",
startDate: "2026-06-21T14:00:00.000Z",
endDate: "2026-06-21T14:59:59.000Z",
externalId,
};
}
function annotateCallsFor(actionName: string) {
return mocks.annotate.mock.calls.filter(
([arg]) =>
(arg as { action?: { name?: string } })?.action?.name === actionName,
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.createManyAndReturn).mockImplementation((async (
args: unknown,
) => {
const { data } = args as {
data:
| { type: unknown; source: string; externalId: string | null }
| Array<{ type: unknown; source: string; externalId: string | null }>;
};
const rows = Array.isArray(data) ? data : [data];
return rows.map(({ type, source, externalId }) => ({
type,
source,
externalId,
}));
}) as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
});
describe("POST /api/measurements/batch — HR-bucket observability (issue #585)", () => {
it("reports a per-outcome breakdown covering only HR-bucket entries", async () => {
// REPOST_BUCKET_ID already exists — the reconcile mock overwrites it.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
type: "PULSE",
source: "APPLE_HEALTH",
externalId: REPOST_BUCKET_ID,
},
] as never);
const res = await POST(
makeRequest({
entries: [
hrEntry(FRESH_BUCKET_ID, 72), // inserted
hrEntry(REPOST_BUCKET_ID, 78), // updated (overwrite)
hrEntry(MALFORMED_BUCKET_ID, 70), // skipped (malformed)
hrEntry(SAMPLE_ID, 64), // NOT an HR bucket — must be excluded
],
}),
);
expect(res.status).toBe(200);
const calls = annotateCallsFor("measurement.batch.hr-bucket");
expect(calls).toHaveLength(1);
const meta = (calls[0][0] as { meta: Record<string, number> }).meta;
expect(meta).toEqual({
attempted: 3,
inserted: 1,
updated: 1,
duplicate: 0,
skipped: 1,
failed: 0,
processed: 4,
});
});
it("never fires the HR-bucket annotation for a batch with no bucket entries", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
const res = await POST(makeRequest({ entries: [hrEntry(SAMPLE_ID, 64)] }));
expect(res.status).toBe(200);
expect(annotateCallsFor("measurement.batch.hr-bucket")).toHaveLength(0);
});
it("breaks the baseline ingest annotation's skip count down by reason", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
const res = await POST(
makeRequest({
entries: [
hrEntry(MALFORMED_BUCKET_ID, 70), // skipped: malformed_hr_bucket_id
],
}),
);
expect(res.status).toBe(200);
const calls = annotateCallsFor("measurement.batch.ingest");
expect(calls).toHaveLength(1);
const meta = calls[0][0] as { meta: { skipped_by_reason: unknown } };
expect(meta.meta.skipped_by_reason).toEqual({
malformed_hr_bucket_id: 1,
});
});
});
+73
View File
@@ -603,6 +603,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
.filter((r) => r.status === "skipped")
.map((r) => ({ index: r.index, reason: r.reason ?? "unknown" }));
// v1.32.1 (issue #585) — per-reason skip breakdown. The baseline
// annotation below only ever carried the total `skipped` count, so an
// operator investigating "why does this account's uploaded data look
// sparser than expected" had no way to see WHICH validation gate an
// entry tripped (malformed bucket id, out-of-range value, unmappable
// identifier, …) without pulling raw rows. Grouping by reason turns
// that into a one-glance wide-event field.
const skippedByReason: Record<string, number> = {};
for (const s of skipped) {
skippedByReason[s.reason] = (skippedByReason[s.reason] ?? 0) + 1;
}
await auditLog("measurement.batch.ingest", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -648,6 +660,64 @@ async function postBatch(request: NextRequest): Promise<Response> {
});
}
// v1.32.1 (issue #585) — dedicated outcome breakdown for entries
// targeting the go-forward aggregated 10-min heart-rate bucket prefix
// (`stats:HKQuantityTypeIdentifierHeartRate:<10-min-Z>`). The intraday
// pulse chart reads exactly these rows for a live-synced day; a report
// of "the chart looks sparser after the ZIP import cutoff" is
// unanswerable from the baseline ingest trace alone, since it never
// distinguished an HR-bucket write from any other entry in the batch.
// Grepping `measurement.batch.hr-bucket` now shows, per sync, how many
// bucket entries the client sent and what happened to each — confirming
// (or ruling out) server-side rejection/dedup as the density-gap cause.
// Only fires when the batch actually carried at least one HR-bucket
// entry so the baseline trace is unchanged for every other sync.
let hrBucketInserted = 0;
let hrBucketUpdated = 0;
let hrBucketDuplicate = 0;
let hrBucketSkipped = 0;
let hrBucketFailed = 0;
for (let index = 0; index < entries.length; index++) {
if (!targetsAggregatedBucket(entries[index].externalId)) continue;
switch (results[index]?.status) {
case "inserted":
hrBucketInserted++;
break;
case "updated":
hrBucketUpdated++;
break;
case "duplicate":
hrBucketDuplicate++;
break;
case "skipped":
hrBucketSkipped++;
break;
case "failed":
hrBucketFailed++;
break;
}
}
const hrBucketAttempted =
hrBucketInserted +
hrBucketUpdated +
hrBucketDuplicate +
hrBucketSkipped +
hrBucketFailed;
if (hrBucketAttempted > 0) {
annotate({
action: { name: "measurement.batch.hr-bucket" },
meta: {
attempted: hrBucketAttempted,
inserted: hrBucketInserted,
updated: hrBucketUpdated,
duplicate: hrBucketDuplicate,
skipped: hrBucketSkipped,
failed: hrBucketFailed,
processed: entries.length,
},
});
}
// v1.4.25 W16c — kick off PR detection for this user. We always
// enqueue when at least one row was written (or the batch had any
// measurements to consider) so a single off-day reading still gets
@@ -693,6 +763,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
updated: updatedCount,
duplicates: duplicateCount,
skipped: skipped.length,
// v1.32.1 (issue #585) — per-reason skip breakdown; see the
// `skippedByReason` build above.
skipped_by_reason: skippedByReason,
failed: failedCount,
},
});