Files
HealthLog/tests/integration/measurements-batch.test.ts
T
Marc-André Bombeck 394f0b0795 chore(release): v1.5.0 — native iOS client public-beta + per-day stats overwrite + compliance fix
v1.5.0 cut. The native iOS client is now publicly available via TestFlight (https://testflight.apple.com/join/bucuTBpa), and three issues that surfaced during the iOS handoff land alongside it.

## Features

- **stats:* upsert** (closes #213) — `POST /api/measurements/batch` recognises `externalId` values starting with `stats:` (per-day cumulative HK metrics: Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed) and treats a duplicate on those as an overwrite of the existing row's `value` rather than dropping the new payload. Sample-class externalIds keep the strict immutable duplicate contract because each sample is a canonical reading. New per-entry status `"updated"` on the response envelope, new `measurement.batch.stats-overwrite` wide-event annotation, audit-log entry extended with the overwrite count. Three integration tests pin the contract.

## Fixes

- **Compliance honour daysOfWeek + intervalWeeks** (closes #214) — `calculateCompliance` was computing `totalExpected = schedules.length * days`, silently ignoring `MedicationSchedule.daysOfWeek` and `intervalWeeks`. Weekly Ozempic reported ~13% adherence instead of ~100%; weekday-only 3×/day metformin reported ~73% instead of ~100%. Rewrote as a thin adapter on top of the cadence-aware `buildCadenceTimeline` path that already powers `/api/medications/[id]/cadence`. The wire shape (`{ totalExpected, taken, skipped, missed, rate, streak }`) is unchanged so every consumer (Health Score, AI Coach prompt, per-medication card, BP-status compliance gate, insight targets, `/api/insights/comprehensive`) keeps reading the same fields. Parameterised test matrix with 35 cases covers daily / weekly / bi-weekly / weekday-only / multi-dose-per-day / DST-boundary / createdAt-truncation. Health-Score regression test pinned.

## Tests

- 8 stale integration assertions retired across `withings-oauth.test.ts`, `withings-oauth-flow.test.ts`, `analytics-bp-aggregate-paged.test.ts`, `analytics-sleep-stages.test.ts`, `apns-dispatch.test.ts` — drift from the v1.4.47.x OAuth fine-grained reason tags, the v1.4.47.2 ES256 PEM verify guard, and the v1.4.49.1 analytics slim-slice annotation rename. Three `source-priority-two-axis` cases skipped with inline TODO referencing the v1.4.49.1 commit and the relocation candidate.
- New cadence test matrix anchored explicitly in UTC so the suite stays deterministic across CI (UTC) and local (Europe/Berlin) hosts.

## Docs

- README rewrite for the v1.5 cut: TestFlight badge in the badge row + iOS TestFlight link in Website / Demo / Docs row + footer. Buy Me A Coffee badge added. Status block updated, new "Heavily developed" advisory directs self-hosters to pin a tag and back up before each upgrade. Tech-Stack flags the iOS app as TestFlight-available. Roadmap promotes v1.5 from "in active development" to "current". The "How it works" diagram cluster removed — the four SVGs continue to live under `docs/diagrams/` and are surfaced through docs.healthlog.dev where they render reliably across themes.

## Final verify

- typecheck clean
- lint clean (one pre-existing warning in `withings/resume/__tests__`)
- pnpm test: 5285 passed / 1 skipped
- pnpm test:integration: 253 passed / 3 skipped
- GHA CI on PR: build amd64 + build arm64 + lint/typecheck/test + integration + e2e + dependency audit + secret scanning + dead-code gate + env check → all green
2026-05-24 22:22:30 +02:00

702 lines
24 KiB
TypeScript

/**
* Integration suite for `POST /api/measurements/batch` — the iOS Apple
* Health ingest endpoint. Asserts the contract the iOS client relies on:
* - Mixed batches succeed end-to-end
* - Re-posting the same batch yields an idempotent outcome
* - Over-cap batches return 422 with the documented error code
* - Unmappable identifiers and out-of-range values surface as `skipped`
* - The composite unique index dedupes by `(user_id, type, source, external_id)`
* - Idempotency-Key replay returns the cached envelope
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-batch-ingest-test";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "batch-ingest",
email: "batch-ingest@example.test",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
interface BatchEntryFixture {
hkIdentifier: string;
value: number;
unit: string;
startDate: string;
endDate: string;
externalId: string;
externalSourceVersion?: string;
sleepStage?: number;
}
function makeRequest(
body: { entries: BatchEntryFixture[] },
opts: { idempotencyKey?: string } = {},
): NextRequest {
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (opts.idempotencyKey) {
headers["idempotency-key"] = opts.idempotencyKey;
}
return new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers,
body: JSON.stringify(body),
});
}
describe("POST /api/measurements/batch (real Postgres)", () => {
it("inserts a well-formed batch and returns per-entry status", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const body = {
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.4,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: "uuid-weight-001",
},
{
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 64,
unit: "count/min",
startDate: "2026-05-09T08:00:00.000Z",
endDate: "2026-05-09T08:00:00.000Z",
externalId: "uuid-pulse-001",
},
{
hkIdentifier: "HKQuantityTypeIdentifierOxygenSaturation",
value: 0.97,
unit: "fraction",
startDate: "2026-05-09T08:05:00.000Z",
endDate: "2026-05-09T08:05:00.000Z",
externalId: "uuid-spo2-001",
},
],
};
const response = await POST(makeRequest(body));
expect(response.status).toBe(200);
const json = (await response.json()) as {
data: {
processed: number;
inserted: number;
duplicates: number;
skipped: Array<{ index: number; reason: string }>;
};
};
expect(json.data.processed).toBe(3);
expect(json.data.inserted).toBe(3);
expect(json.data.duplicates).toBe(0);
expect(json.data.skipped).toHaveLength(0);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
orderBy: { measuredAt: "asc" },
});
expect(stored).toHaveLength(3);
expect(stored.every((r) => r.source === "APPLE_HEALTH")).toBe(true);
const spo2 = stored.find((r) => r.type === "OXYGEN_SATURATION");
expect(spo2?.value).toBeCloseTo(97);
expect(spo2?.unit).toBe("%");
});
it("dedupes a re-posted batch into duplicate status without inserting twice", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const body = {
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.5,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: "uuid-dup-001",
},
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.6,
unit: "kg",
startDate: "2026-05-09T08:00:00.000Z",
endDate: "2026-05-09T08:00:00.000Z",
externalId: "uuid-dup-002",
},
],
};
const first = await POST(makeRequest(body));
expect(first.status).toBe(200);
const firstJson = (await first.json()) as { data: { inserted: number } };
expect(firstJson.data.inserted).toBe(2);
// Re-post the SAME body with NO idempotency-key. Per-entry dedup
// should kick in via the composite unique index, returning all
// duplicates and inserting nothing new.
const second = await POST(makeRequest(body));
expect(second.status).toBe(200);
const secondJson = (await second.json()) as {
data: { inserted: number; duplicates: number };
};
expect(secondJson.data.inserted).toBe(0);
expect(secondJson.data.duplicates).toBe(2);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored).toHaveLength(2);
});
it("returns 422 with measurement.batch.too_large when entries exceed the cap", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const entries: BatchEntryFixture[] = [];
for (let i = 0; i < 501; i++) {
entries.push({
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 80 + (i % 10) * 0.1,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: `uuid-cap-${i}`,
});
}
const response = await POST(makeRequest({ entries }));
expect(response.status).toBe(422);
const json = (await response.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(json.error).toMatch(/500/);
expect(json.meta?.errorCode).toBe("measurement.batch.too_large");
});
it("flags unmappable identifiers and out-of-range values as skipped", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const body = {
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierHallucinated",
value: 42,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: "uuid-skip-unknown",
},
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 9999, // out of plausible range
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: "uuid-skip-range",
},
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.5,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: "uuid-good-001",
},
],
};
const response = await POST(makeRequest(body));
expect(response.status).toBe(200);
const json = (await response.json()) as {
data: {
processed: number;
inserted: number;
skipped: Array<{ index: number; reason: string }>;
};
};
expect(json.data.processed).toBe(3);
expect(json.data.inserted).toBe(1);
expect(json.data.skipped).toEqual(
expect.arrayContaining([
{ index: 0, reason: "unmappable_identifier" },
{ index: 1, reason: "value_out_of_range" },
]),
);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored).toHaveLength(1);
expect(stored[0]?.externalId).toBe("uuid-good-001");
});
it("inserts per-stage sleep rows with the sleepStage column populated", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const body = {
entries: [
{
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
value: 60, // minutes
unit: "min",
startDate: "2026-05-09T01:00:00.000Z",
endDate: "2026-05-09T02:00:00.000Z",
externalId: "uuid-sleep-core-001",
sleepStage: 3, // CORE
},
{
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
value: 90,
unit: "min",
startDate: "2026-05-09T02:00:00.000Z",
endDate: "2026-05-09T03:30:00.000Z",
externalId: "uuid-sleep-deep-001",
sleepStage: 4, // DEEP
},
{
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
value: 25,
unit: "min",
startDate: "2026-05-09T03:30:00.000Z",
endDate: "2026-05-09T03:55:00.000Z",
externalId: "uuid-sleep-rem-001",
sleepStage: 5, // REM
},
],
};
const response = await POST(makeRequest(body));
expect(response.status).toBe(200);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, type: "SLEEP_DURATION" },
orderBy: { measuredAt: "asc" },
});
expect(stored).toHaveLength(3);
expect(stored.map((r) => r.sleepStage)).toEqual(["CORE", "DEEP", "REM"]);
expect(stored.every((r) => r.unit === "minutes")).toBe(true);
});
// v1.4.25 W10 reconcile (senior-dev H-1): under contention the
// per-entry envelope MUST stay in sync with the aggregate
// `inserted` / `duplicate` counts. The previous reconciliation
// block was an effective no-op (it looked for rows that were
// "marked inserted but missing from the DB" — an impossibility
// because the raced row IS in the DB, written by the other
// batch). The corrected block downgrades enough "inserted"
// statuses to "duplicate" so the per-entry envelope and the
// aggregate counts agree, which the iOS sync cursor depends on
// to checkpoint correctly.
it("keeps per-entry status in sync with aggregate counts under a concurrent-write race", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
// Two batches with overlapping externalIds posted in parallel.
// The composite unique index ensures only one row per
// (user, type, source, externalId) lands; `skipDuplicates`
// absorbs duplicate-key conflicts. The exact split between
// "won the race" and "got absorbed" depends on Postgres's
// commit order, so we assert only the invariant the iOS sync
// cursor relies on: per-entry envelope sums equal the aggregate
// counts for each response.
const sharedEntries = () =>
Array.from({ length: 6 }, (_, i) => ({
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 80 + i * 0.1,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: `uuid-race-${i}`,
}));
const [first, second] = await Promise.all([
POST(makeRequest({ entries: sharedEntries() })),
POST(makeRequest({ entries: sharedEntries() })),
]);
expect(first.status).toBe(200);
expect(second.status).toBe(200);
const firstJson = (await first.json()) as {
data: {
processed: number;
inserted: number;
duplicates: number;
entries: Array<{ status: string }>;
};
};
const secondJson = (await second.json()) as typeof firstJson;
// Invariant 1 — per-entry statuses sum to the aggregate counts.
// Before the fix this failed under contention because the
// no-op reconciliation left the envelope out of sync.
for (const json of [firstJson, secondJson]) {
const insertedEntries = json.data.entries.filter(
(e) => e.status === "inserted",
).length;
const duplicateEntries = json.data.entries.filter(
(e) => e.status === "duplicate",
).length;
const skippedEntries = json.data.entries.filter(
(e) => e.status === "skipped",
).length;
expect(insertedEntries).toBe(json.data.inserted);
expect(duplicateEntries).toBe(json.data.duplicates);
expect(
insertedEntries + duplicateEntries + skippedEntries,
).toBe(json.data.processed);
}
// Invariant 2 — aggregate counts are non-negative. The previous
// logic could not produce a negative `inserted` count but a
// naive "downgrade and also decrement" implementation can.
expect(firstJson.data.inserted).toBeGreaterThanOrEqual(0);
expect(secondJson.data.inserted).toBeGreaterThanOrEqual(0);
// Invariant 3 — the DB ends up with at most 6 rows because the
// composite unique index enforces single-copy, and the
// `inserted` counts across both requests cannot exceed the
// number of rows actually present.
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored.length).toBeLessThanOrEqual(6);
expect(firstJson.data.inserted + secondJson.data.inserted).toBe(
stored.length,
);
});
it("rate-limits a user at 60 batches per minute (security H-2)", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
// Pre-seed the rate-limit counter to the cap so the next call
// exercises the over-limit path without 60 round-trips.
const cap = 60;
const resetAt = new Date(Date.now() + 60 * 1000);
await getPrismaClient().$executeRaw`
INSERT INTO rate_limits (key, count, reset_at)
VALUES (${`measurements:batch:${TEST_USER_ID}`}, ${cap}, ${resetAt})
`;
const body = {
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.4,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: "uuid-rate-limit-001",
},
],
};
const response = await POST(makeRequest(body));
expect(response.status).toBe(429);
const json = (await response.json()) as { error: string };
expect(json.error).toMatch(/too many/i);
// The over-limit response must not have written anything to the
// measurement table — the iOS client should retry, not assume the
// row landed.
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored).toHaveLength(0);
});
it("replays a cached response when the same Idempotency-Key is reused", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const body = {
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.7,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: "uuid-idem-001",
},
],
};
const key = "ios-batch-12345678";
const first = await POST(makeRequest(body, { idempotencyKey: key }));
expect(first.status).toBe(200);
const firstJson = (await first.json()) as { data: { inserted: number } };
expect(firstJson.data.inserted).toBe(1);
const second = await POST(makeRequest(body, { idempotencyKey: key }));
expect(second.status).toBe(200);
expect(second.headers.get("X-Idempotent-Replay")).toBe("true");
const secondJson = (await second.json()) as { data: { inserted: number } };
// Same envelope as the first call — even though it would otherwise
// have surfaced as a duplicate.
expect(secondJson.data.inserted).toBe(1);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, externalId: "uuid-idem-001" },
});
expect(stored).toHaveLength(1);
});
// v1.5.0 issue #213 — per-day cumulative `stats:*` externalIds are
// re-posted by the iOS HealthKit observer throughout the day. Prior
// to this fix the server returned `status: "duplicate"` and silently
// dropped the new value, freezing today's tile at the first-sync
// total. Sample-class externalIds (every other prefix) keep the
// immutable duplicate contract because each sample is canonical.
describe("stats:* externalId overwrite (issue #213)", () => {
it("re-posting a stats:* day-aggregate row overwrites the value and returns status='updated'", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const externalId =
"stats:HKQuantityTypeIdentifierStepCount:2026-05-24";
const firstBody = {
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value: 300,
unit: "count",
startDate: "2026-05-24T00:00:00.000Z",
endDate: "2026-05-24T08:00:00.000Z",
externalId,
},
],
};
const first = await POST(makeRequest(firstBody));
expect(first.status).toBe(200);
const firstJson = (await first.json()) as {
data: {
inserted: number;
updated: number;
duplicates: number;
entries: Array<{ status: string }>;
};
};
expect(firstJson.data.inserted).toBe(1);
expect(firstJson.data.updated).toBe(0);
expect(firstJson.data.duplicates).toBe(0);
expect(firstJson.data.entries[0]?.status).toBe("inserted");
const secondBody = {
entries: [
{
...firstBody.entries[0],
value: 5200,
endDate: "2026-05-24T16:00:00.000Z",
},
],
};
const second = await POST(makeRequest(secondBody));
expect(second.status).toBe(200);
const secondJson = (await second.json()) as {
data: {
inserted: number;
updated: number;
duplicates: number;
entries: Array<{ status: string }>;
};
};
expect(secondJson.data.inserted).toBe(0);
expect(secondJson.data.updated).toBe(1);
expect(secondJson.data.duplicates).toBe(0);
expect(secondJson.data.entries[0]?.status).toBe("updated");
// The single row on disk reflects the latest re-post.
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, externalId },
});
expect(stored).toHaveLength(1);
expect(stored[0]!.value).toBeCloseTo(5200, 5);
});
it("sample-class externalIds (non-stats:* prefix) keep the strict duplicate contract", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const externalId = "uuid-pulse-sample-001";
const body = {
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 64,
unit: "count/min",
startDate: "2026-05-24T08:00:00.000Z",
endDate: "2026-05-24T08:00:00.000Z",
externalId,
},
],
};
const first = await POST(makeRequest(body));
expect(first.status).toBe(200);
expect(((await first.json()) as { data: { inserted: number } }).data.inserted)
.toBe(1);
// Replay with a different value — must NOT overwrite because the
// externalId is not stats:*. Sample-class rows are immutable.
const replayBody = {
entries: [
{
...body.entries[0],
value: 200,
},
],
};
const second = await POST(makeRequest(replayBody));
expect(second.status).toBe(200);
const secondJson = (await second.json()) as {
data: { inserted: number; updated: number; duplicates: number };
};
expect(secondJson.data.inserted).toBe(0);
expect(secondJson.data.updated).toBe(0);
expect(secondJson.data.duplicates).toBe(1);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, externalId },
});
expect(stored).toHaveLength(1);
// First-write wins — sample-class duplicates do not overwrite.
expect(stored[0]!.value).toBeCloseTo(64, 5);
});
it("a mixed batch with one new + one stats:* overwrite + one sample-class duplicate returns all three statuses", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
// Seed: one stats:* steps row (will be overwritten) + one
// sample-class pulse row (will surface as duplicate on replay).
const stepsExternalId =
"stats:HKQuantityTypeIdentifierStepCount:2026-05-25";
const pulseExternalId = "uuid-pulse-mixed-001";
await POST(
makeRequest({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value: 1200,
unit: "count",
startDate: "2026-05-25T00:00:00.000Z",
endDate: "2026-05-25T09:00:00.000Z",
externalId: stepsExternalId,
},
{
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 70,
unit: "count/min",
startDate: "2026-05-25T09:00:00.000Z",
endDate: "2026-05-25T09:00:00.000Z",
externalId: pulseExternalId,
},
],
}),
);
// Second batch: stats:* re-post (overwrite) + sample-class
// replay (duplicate) + a brand-new pulse sample (insert).
const second = await POST(
makeRequest({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value: 8400,
unit: "count",
startDate: "2026-05-25T00:00:00.000Z",
endDate: "2026-05-25T18:00:00.000Z",
externalId: stepsExternalId,
},
{
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 70,
unit: "count/min",
startDate: "2026-05-25T09:00:00.000Z",
endDate: "2026-05-25T09:00:00.000Z",
externalId: pulseExternalId,
},
{
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 88,
unit: "count/min",
startDate: "2026-05-25T15:00:00.000Z",
endDate: "2026-05-25T15:00:00.000Z",
externalId: "uuid-pulse-mixed-002",
},
],
}),
);
expect(second.status).toBe(200);
const json = (await second.json()) as {
data: {
inserted: number;
updated: number;
duplicates: number;
entries: Array<{ index: number; status: string }>;
};
};
expect(json.data.inserted).toBe(1);
expect(json.data.updated).toBe(1);
expect(json.data.duplicates).toBe(1);
const byIndex = new Map(json.data.entries.map((e) => [e.index, e.status]));
expect(byIndex.get(0)).toBe("updated");
expect(byIndex.get(1)).toBe("duplicate");
expect(byIndex.get(2)).toBe("inserted");
const stepsRow = await getPrismaClient().measurement.findFirst({
where: { userId: TEST_USER_ID, externalId: stepsExternalId },
});
expect(stepsRow?.value).toBeCloseTo(8400, 5);
});
});
});