Merge w11b/a4hrwire into release/v1.19.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-21 10:22:36 +02:00
commit 837f215f66
6 files changed
+540 -19

No files matched your search

@@ -0,0 +1,242 @@
/**
* v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire contract on
* `POST /api/measurements/batch`.
*
* Asserts:
* - a fresh hourly HR bucket inserts;
* - a re-post of the same hour OVERWRITES (status `updated`, not a
* duplicate row);
* - a malformed hourly-HR bucket externalId is `skipped`;
* - the per-sample uuid HR path is unaffected (immutable duplicate);
* - the existing per-day step `stats:` overwrite path is unaffected.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: vi.fn(),
createMany: 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/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 HR_BUCKET_ID =
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000Z";
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 hrBucketEntry(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,
};
}
async function readJson(res: Response) {
return (await res.json()) as {
data: {
processed: number;
inserted: number;
updated: number;
duplicates: number;
skipped: { index: number; reason: string }[];
entries: { index: number; status: string; reason?: string }[];
};
};
}
beforeEach(() => {
// `clearAllMocks` (not `resetAllMocks`) so the factory `$transaction`
// pass-through implementation survives — the overwrite path sets its
// per-entry status INSIDE the transaction, and a wiped impl would leave
// the result slot undefined and 500 the route.
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.findMany).mockResolvedValue([]);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
});
describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", () => {
it("inserts a fresh hourly HR bucket as a PULSE row", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72)] }),
);
expect(res.status).toBe(200);
const { data } = await readJson(res);
expect(data.inserted).toBe(1);
expect(data.updated).toBe(0);
expect(data.entries[0].status).toBe("inserted");
// Stored as a PULSE row carrying the hourly average value.
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
const rows = (createArg as { data: { type: string; value: number }[] })
.data;
expect(rows[0].type).toBe("PULSE");
expect(rows[0].value).toBe(72);
});
it("overwrites the same hour bucket on a re-post (updated, not duplicate)", async () => {
// The bucket already exists from an earlier within-hour post.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
type: "PULSE",
source: "APPLE_HEALTH",
externalId: HR_BUCKET_ID,
},
] as never);
const res = await POST(
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 78)] }),
);
expect(res.status).toBe(200);
const { data } = await readJson(res);
expect(data.updated).toBe(1);
expect(data.inserted).toBe(0);
expect(data.duplicates).toBe(0);
expect(data.entries[0].status).toBe("updated");
// The overwrite went through updateMany with the new hourly average.
expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1);
const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
expect((updateArg as { data: { value: number } }).data.value).toBe(78);
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
});
it("skips a malformed hourly HR bucket externalId", async () => {
const res = await POST(
makeRequest({
entries: [
hrBucketEntry(
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:30:00.000Z",
70,
),
],
}),
);
expect(res.status).toBe(200);
const { data } = await readJson(res);
expect(data.inserted).toBe(0);
expect(data.skipped).toEqual([
{ index: 0, reason: "malformed_hr_bucket_id" },
]);
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
});
it("keeps the per-sample uuid HR path immutable (duplicate, not overwrite)", async () => {
const SAMPLE_ID = "B3A1C0DE-0000-4000-8000-000000000000";
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "PULSE", source: "APPLE_HEALTH", externalId: SAMPLE_ID },
] as never);
const res = await POST(
makeRequest({ entries: [hrBucketEntry(SAMPLE_ID, 99)] }),
);
expect(res.status).toBe(200);
const { data } = await readJson(res);
expect(data.duplicates).toBe(1);
expect(data.updated).toBe(0);
expect(data.entries[0].status).toBe("duplicate");
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
});
it("leaves the per-day step stats overwrite path unaffected", async () => {
const STEP_STATS_ID = "stats:HKQuantityTypeIdentifierStepCount:2026-06-21";
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
type: "ACTIVITY_STEPS",
source: "APPLE_HEALTH",
externalId: STEP_STATS_ID,
},
] as never);
const res = await POST(
makeRequest({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value: 8421,
unit: "count",
startDate: "2026-06-21T00:00:00.000Z",
endDate: "2026-06-21T23:59:59.000Z",
externalId: STEP_STATS_ID,
},
],
}),
);
expect(res.status).toBe(200);
const { data } = await readJson(res);
expect(data.updated).toBe(1);
expect(data.entries[0].status).toBe("updated");
});
});
+41 -8
View File
@@ -18,13 +18,20 @@
* HealthKit sample, surface as `duplicate` per entry instead of
* hard-failing the batch.
* - v1.5.0 issue #213 — entries whose `externalId` starts with
* `stats:` are per-day cumulative totals (Steps, Active Energy,
* Sleep Duration, Walking/Running Distance, Flights Climbed). The
* iOS HealthKit observer re-posts those throughout the day as the
* running total changes. They surface as `updated` (the row's
* value is overwritten) rather than `duplicate` (the new value
* dropped). Sample-class externalIds keep the strict immutable
* `duplicate` contract.
* `stats:` are aggregate rows the iOS HealthKit observer re-posts as
* the underlying window fills. Two granularities ride the same
* overwrite mechanism:
* · per-day cumulative totals — `stats:<HK>:<YYYY-MM-DD>` (Steps,
* Active Energy, Sleep Duration, Walking/Running Distance,
* Flights Climbed);
* · v1.19.0 (iOS #34) hourly heart-rate buckets —
* `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start-hour>`
* carrying the hour's AVERAGE bpm as one PULSE row, so iOS stops
* uploading one row per raw HR sample.
* Both surface as `updated` (the row's value is overwritten) rather
* than `duplicate` (the new value dropped). Sample-class externalIds
* (every other prefix) keep the strict immutable `duplicate`
* contract.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -40,7 +47,11 @@ import {
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping";
import {
mapAppleHealthEntry,
isHourlyHeartRateStatsExternalId,
targetsHourlyHeartRateBucket,
} from "@/lib/measurements/apple-health-mapping";
import {
MEASURED_AT_TOLERANCE_MS,
isMergeableSource,
@@ -268,6 +279,28 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
// v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row
// is the hourly-average PULSE bucket; it rides the generic `stats:`
// overwrite path below so a within-hour re-post (the running mean
// shifts) replaces the value instead of duplicating. Reject a row
// that targets the HR-bucket prefix but carries a malformed hour
// suffix — a garbage suffix would mint an un-overwriteable row that
// the next hour's re-post can't collapse onto. A well-formed bucket
// (and every non-HR `stats:` / per-sample `uuid` externalId) passes
// through unchanged.
if (
targetsHourlyHeartRateBucket(entry.externalId) &&
!isHourlyHeartRateStatsExternalId(entry.externalId)
) {
results[index] = {
index,
status: "skipped",
reason: "malformed_hr_bucket_id",
};
continue;
}
prepared.push({
index,
entry,