mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge w11b/a4hrwire into release/v1.19.0
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user