mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
1332 lines
45 KiB
TypeScript
1332 lines
45 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 { mkdtempSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export";
|
|
|
|
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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
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;
|
|
source?: string;
|
|
}
|
|
|
|
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),
|
|
});
|
|
}
|
|
|
|
function writeCumulativeXml(firstValue = 8_526, secondValue = 7_082): string {
|
|
const tmp = mkdtempSync(join(tmpdir(), "healthlog-aggregate-authority-"));
|
|
const xmlPath = join(tmp, "export.xml");
|
|
writeFileSync(
|
|
xmlPath,
|
|
`<?xml version="1.0" encoding="UTF-8"?>
|
|
<HealthData locale="en_US">
|
|
<Record type="HKQuantityTypeIdentifierStepCount" unit="count"
|
|
startDate="2026-07-20 08:00:00 +0200"
|
|
endDate="2026-07-20 08:30:00 +0200"
|
|
value="${firstValue}" sourceName="iPhone" sourceVersion="18.5"/>
|
|
<Record type="HKQuantityTypeIdentifierStepCount" unit="count"
|
|
startDate="2026-07-20 09:00:00 +0200"
|
|
endDate="2026-07-20 09:30:00 +0200"
|
|
value="${secondValue}" sourceName="Zepp" sourceVersion="9.1"/>
|
|
</HealthData>`,
|
|
);
|
|
return xmlPath;
|
|
}
|
|
|
|
function nativeStepsRequest(value: number): NextRequest {
|
|
return makeRequest({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
|
|
value,
|
|
unit: "count",
|
|
startDate: "2026-07-20T00:00:00.000Z",
|
|
endDate: "2026-07-20T18:00:00.000Z",
|
|
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
// Under contention every per-entry verdict must stay in sync with its
|
|
// aggregate counter. Inserted, updated, duplicate, skipped, and failed are
|
|
// all explicit because only the hard failure verdict must block checkpoint
|
|
// advancement.
|
|
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 external ids and the same natural identity
|
|
// are posted in parallel. The reconciler serializes both identity indexes;
|
|
// the exact inserted/updated split depends on commit order, but every
|
|
// response must remain internally consistent.
|
|
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;
|
|
updated: number;
|
|
failed: 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(
|
|
(entry) => entry.status === "inserted",
|
|
).length;
|
|
const updatedEntries = json.data.entries.filter(
|
|
(entry) => entry.status === "updated",
|
|
).length;
|
|
const duplicateEntries = json.data.entries.filter(
|
|
(entry) => entry.status === "duplicate",
|
|
).length;
|
|
const skippedEntries = json.data.entries.filter(
|
|
(entry) => entry.status === "skipped",
|
|
).length;
|
|
const failedEntries = json.data.entries.filter(
|
|
(entry) => entry.status === "failed",
|
|
).length;
|
|
expect(insertedEntries).toBe(json.data.inserted);
|
|
expect(updatedEntries).toBe(json.data.updated);
|
|
expect(duplicateEntries).toBe(json.data.duplicates);
|
|
expect(failedEntries).toBe(json.data.failed);
|
|
expect(
|
|
insertedEntries +
|
|
updatedEntries +
|
|
duplicateEntries +
|
|
skippedEntries +
|
|
failedEntries,
|
|
).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 — all samples share one natural identity, so exactly one
|
|
// canonical row survives and only one request can report its insertion.
|
|
const stored = await getPrismaClient().measurement.findMany({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(stored).toHaveLength(1);
|
|
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);
|
|
expect(stored[0]).toMatchObject({
|
|
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
|
syncVersion: 2,
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
// v1.8.6 — optional per-entry `source`. The standalone iOS client
|
|
// tags adopt-on-pair backfill rows it knows were entered by hand as
|
|
// `MANUAL`; every other caller (web + current iOS) omits the field and
|
|
// must keep storing rows as `APPLE_HEALTH`. `source` is part of the
|
|
// `(userId, type, source, externalId)` dedup key.
|
|
describe("optional source field (W6)", () => {
|
|
it("persists a row as APPLE_HEALTH when source is absent (backward-compat)", async () => {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
|
|
const response = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 80.2,
|
|
unit: "kg",
|
|
startDate: "2026-05-09T07:30:00.000Z",
|
|
endDate: "2026-05-09T07:30:00.000Z",
|
|
externalId: "uuid-source-absent-001",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
const stored = await getPrismaClient().measurement.findMany({
|
|
where: { userId: TEST_USER_ID, externalId: "uuid-source-absent-001" },
|
|
});
|
|
expect(stored).toHaveLength(1);
|
|
expect(stored[0]!.source).toBe("APPLE_HEALTH");
|
|
});
|
|
|
|
it("persists a row as MANUAL when source is explicitly MANUAL", async () => {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
|
|
const response = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 79.8,
|
|
unit: "kg",
|
|
startDate: "2026-05-09T07:30:00.000Z",
|
|
endDate: "2026-05-09T07:30:00.000Z",
|
|
externalId: "uuid-source-manual-001",
|
|
source: "MANUAL",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const json = (await response.json()) as { data: { inserted: number } };
|
|
expect(json.data.inserted).toBe(1);
|
|
|
|
const stored = await getPrismaClient().measurement.findMany({
|
|
where: { userId: TEST_USER_ID, externalId: "uuid-source-manual-001" },
|
|
});
|
|
expect(stored).toHaveLength(1);
|
|
expect(stored[0]!.source).toBe("MANUAL");
|
|
});
|
|
|
|
it("treats the same externalId under different sources as two distinct rows", async () => {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
|
|
// The two rows share an externalId but carry DIFFERENT values +
|
|
// timestamps so they are genuinely distinct readings — this asserts
|
|
// that `source` is part of the composite dedup key, without tripping
|
|
// the v1.11.4 cross-source same-reading merge (which only collapses a
|
|
// MANUAL + APPLE_HEALTH pair that share value + measuredAt).
|
|
const sharedExternalId = "uuid-source-collision-001";
|
|
const response = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 81.0,
|
|
unit: "kg",
|
|
startDate: "2026-05-09T07:30:00.000Z",
|
|
endDate: "2026-05-09T07:30:00.000Z",
|
|
externalId: sharedExternalId,
|
|
source: "APPLE_HEALTH",
|
|
},
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 79.5,
|
|
unit: "kg",
|
|
startDate: "2026-05-09T19:30:00.000Z",
|
|
endDate: "2026-05-09T19:30:00.000Z",
|
|
externalId: sharedExternalId,
|
|
source: "MANUAL",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const json = (await response.json()) as {
|
|
data: { inserted: number; duplicates: number };
|
|
};
|
|
// Distinct dedup keys — both insert, neither is a duplicate.
|
|
expect(json.data.inserted).toBe(2);
|
|
expect(json.data.duplicates).toBe(0);
|
|
|
|
const stored = await getPrismaClient().measurement.findMany({
|
|
where: { userId: TEST_USER_ID, externalId: sharedExternalId },
|
|
});
|
|
expect(stored).toHaveLength(2);
|
|
expect(new Set(stored.map((r) => r.source))).toEqual(
|
|
new Set(["APPLE_HEALTH", "MANUAL"]),
|
|
);
|
|
});
|
|
|
|
// v1.11.4 (iOS #2) — same-reading merge for the MANUAL↔APPLE_HEALTH
|
|
// mirror duplicate. A standalone manual reading mirrors into HealthKit;
|
|
// on pair, adopt-on-pair uploads it as MANUAL and HealthKit background
|
|
// sync re-ingests the mirror as APPLE_HEALTH with a different
|
|
// externalId. The composite unique index would let both land; the
|
|
// cross-source collapse drops the second copy as a `duplicate`.
|
|
describe("cross-source same-reading merge (iOS #2)", () => {
|
|
const weightEntry = (
|
|
over: Partial<BatchEntryFixture>,
|
|
): BatchEntryFixture => ({
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 81.4,
|
|
unit: "kg",
|
|
startDate: "2026-06-04T07:30:00.000Z",
|
|
endDate: "2026-06-04T07:30:00.000Z",
|
|
externalId: "uuid-merge-default",
|
|
...over,
|
|
});
|
|
|
|
it("collapses MANUAL then APPLE_HEALTH same reading into one row (separate batches)", async () => {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
|
|
// Adopt-on-pair MANUAL upload first.
|
|
const first = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
weightEntry({ externalId: "local-manual-001", source: "MANUAL" }),
|
|
],
|
|
}),
|
|
);
|
|
expect(first.status).toBe(200);
|
|
expect(
|
|
((await first.json()) as { data: { inserted: number } }).data
|
|
.inserted,
|
|
).toBe(1);
|
|
|
|
// HealthKit background sync re-ingests the same physical reading
|
|
// as APPLE_HEALTH with a different (HKSample.uuid) externalId.
|
|
const second = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
weightEntry({
|
|
externalId: "hk-uuid-001",
|
|
source: "APPLE_HEALTH",
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
expect(second.status).toBe(200);
|
|
const secondJson = (await second.json()) as {
|
|
data: {
|
|
inserted: number;
|
|
duplicates: number;
|
|
entries: Array<{ status: string; reason?: string }>;
|
|
};
|
|
};
|
|
expect(secondJson.data.inserted).toBe(0);
|
|
expect(secondJson.data.duplicates).toBe(1);
|
|
expect(secondJson.data.entries[0]?.status).toBe("duplicate");
|
|
expect(secondJson.data.entries[0]?.reason).toBe("cross_source_merge");
|
|
|
|
const stored = await getPrismaClient().measurement.findMany({
|
|
where: { userId: TEST_USER_ID, type: "WEIGHT" },
|
|
});
|
|
expect(stored).toHaveLength(1);
|
|
expect(stored[0]!.source).toBe("MANUAL");
|
|
});
|
|
|
|
it("collapses regardless of arrival order (APPLE_HEALTH then MANUAL)", async () => {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
|
|
const first = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
weightEntry({
|
|
externalId: "hk-uuid-002",
|
|
source: "APPLE_HEALTH",
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
expect(first.status).toBe(200);
|
|
|
|
const second = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
weightEntry({ externalId: "local-manual-002", source: "MANUAL" }),
|
|
],
|
|
}),
|
|
);
|
|
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(1);
|
|
|
|
const stored = await getPrismaClient().measurement.findMany({
|
|
where: { userId: TEST_USER_ID, type: "WEIGHT" },
|
|
});
|
|
expect(stored).toHaveLength(1);
|
|
expect(stored[0]!.source).toBe("APPLE_HEALTH");
|
|
});
|
|
|
|
it("collapses a MANUAL + APPLE_HEALTH mirror arriving in the SAME batch", async () => {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
|
|
const response = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
weightEntry({ externalId: "local-manual-003", source: "MANUAL" }),
|
|
weightEntry({
|
|
externalId: "hk-uuid-003",
|
|
source: "APPLE_HEALTH",
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const json = (await response.json()) as {
|
|
data: { inserted: number; duplicates: number };
|
|
};
|
|
expect(json.data.inserted).toBe(1);
|
|
expect(json.data.duplicates).toBe(1);
|
|
|
|
const stored = await getPrismaClient().measurement.findMany({
|
|
where: { userId: TEST_USER_ID, type: "WEIGHT" },
|
|
});
|
|
expect(stored).toHaveLength(1);
|
|
});
|
|
|
|
it("matches within the ±2s tolerance window", async () => {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
|
|
await POST(
|
|
makeRequest({
|
|
entries: [
|
|
weightEntry({
|
|
externalId: "local-manual-tol",
|
|
source: "MANUAL",
|
|
startDate: "2026-06-04T07:30:00.000Z",
|
|
endDate: "2026-06-04T07:30:00.000Z",
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
|
|
// HK mirror ingested with a 1s sub-second drift on the same reading.
|
|
const second = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
weightEntry({
|
|
externalId: "hk-uuid-tol",
|
|
source: "APPLE_HEALTH",
|
|
startDate: "2026-06-04T07:30:01.000Z",
|
|
endDate: "2026-06-04T07:30:01.000Z",
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
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(1);
|
|
|
|
const stored = await getPrismaClient().measurement.findMany({
|
|
where: { userId: TEST_USER_ID, type: "WEIGHT" },
|
|
});
|
|
expect(stored).toHaveLength(1);
|
|
});
|
|
|
|
it("does NOT merge different values (two real readings same minute)", async () => {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
|
|
await POST(
|
|
makeRequest({
|
|
entries: [
|
|
weightEntry({
|
|
externalId: "local-manual-distinct",
|
|
source: "MANUAL",
|
|
value: 81.4,
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
|
|
const second = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
weightEntry({
|
|
externalId: "hk-uuid-distinct",
|
|
source: "APPLE_HEALTH",
|
|
value: 82.0,
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
expect(second.status).toBe(200);
|
|
const secondJson = (await second.json()) as {
|
|
data: { inserted: number; duplicates: number };
|
|
};
|
|
// Different value → genuinely distinct reading, both rows survive.
|
|
expect(secondJson.data.inserted).toBe(1);
|
|
expect(secondJson.data.duplicates).toBe(0);
|
|
|
|
const stored = await getPrismaClient().measurement.findMany({
|
|
where: { userId: TEST_USER_ID, type: "WEIGHT" },
|
|
});
|
|
expect(stored).toHaveLength(2);
|
|
});
|
|
|
|
it("does NOT merge timestamps beyond the tolerance window", async () => {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
|
|
await POST(
|
|
makeRequest({
|
|
entries: [
|
|
weightEntry({
|
|
externalId: "local-manual-far",
|
|
source: "MANUAL",
|
|
startDate: "2026-06-04T07:30:00.000Z",
|
|
endDate: "2026-06-04T07:30:00.000Z",
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
|
|
// A full minute later — distinct reading, not the mirror pair.
|
|
const second = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
weightEntry({
|
|
externalId: "hk-uuid-far",
|
|
source: "APPLE_HEALTH",
|
|
startDate: "2026-06-04T07:31:00.000Z",
|
|
endDate: "2026-06-04T07:31:00.000Z",
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
expect(second.status).toBe(200);
|
|
const secondJson = (await second.json()) as {
|
|
data: { inserted: number; duplicates: number };
|
|
};
|
|
expect(secondJson.data.inserted).toBe(1);
|
|
expect(secondJson.data.duplicates).toBe(0);
|
|
|
|
const stored = await getPrismaClient().measurement.findMany({
|
|
where: { userId: TEST_USER_ID, type: "WEIGHT" },
|
|
});
|
|
expect(stored).toHaveLength(2);
|
|
});
|
|
|
|
it("leaves the stats:* overwrite contract untouched (no cross-source collapse)", async () => {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
|
|
const externalId = "stats:HKQuantityTypeIdentifierStepCount:2026-06-04";
|
|
// A MANUAL same-day-total then an APPLE_HEALTH re-post must NOT
|
|
// collapse — stats:* rows are per-day aggregates, excluded from the
|
|
// merge. They are distinct (type, source) rows and each keeps its
|
|
// own overwrite lane.
|
|
await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
|
|
value: 4000,
|
|
unit: "count",
|
|
startDate: "2026-06-04T00:00:00.000Z",
|
|
endDate: "2026-06-04T12:00:00.000Z",
|
|
externalId,
|
|
source: "MANUAL",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const second = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
|
|
value: 4000,
|
|
unit: "count",
|
|
startDate: "2026-06-04T00:00:00.000Z",
|
|
endDate: "2026-06-04T12:00:00.000Z",
|
|
externalId,
|
|
source: "APPLE_HEALTH",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(second.status).toBe(200);
|
|
const secondJson = (await second.json()) as {
|
|
data: { inserted: number; duplicates: number };
|
|
};
|
|
// Different source on a stats:* externalId → distinct row, inserted.
|
|
expect(secondJson.data.inserted).toBe(1);
|
|
expect(secondJson.data.duplicates).toBe(0);
|
|
|
|
const stored = await getPrismaClient().measurement.findMany({
|
|
where: { userId: TEST_USER_ID, externalId },
|
|
});
|
|
expect(stored).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
it("rejects an out-of-range source value with 422", async () => {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
|
|
const response = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 80.5,
|
|
unit: "kg",
|
|
startDate: "2026-05-09T07:30:00.000Z",
|
|
endDate: "2026-05-09T07:30:00.000Z",
|
|
externalId: "uuid-source-bad-001",
|
|
// WITHINGS is server-owned and not accepted on this
|
|
// client-facing route — must surface as a 422, not silently
|
|
// mint a forged Withings-attributed row.
|
|
source: "WITHINGS",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(response.status).toBe(422);
|
|
|
|
const stored = await getPrismaClient().measurement.findMany({
|
|
where: { userId: TEST_USER_ID, externalId: "uuid-source-bad-001" },
|
|
});
|
|
expect(stored).toHaveLength(0);
|
|
});
|
|
});
|
|
describe("Apple aggregate authority reconciliation", () => {
|
|
it("promotes an XML estimate to native HealthKit statistics", async () => {
|
|
const prisma = getPrismaClient();
|
|
await streamParseExportXml({
|
|
xmlPath: writeCumulativeXml(),
|
|
userId: TEST_USER_ID,
|
|
userTimezone: "Europe/Berlin",
|
|
prisma,
|
|
});
|
|
const estimated = await prisma.measurement.findMany({
|
|
where: {
|
|
userId: TEST_USER_ID,
|
|
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
|
|
},
|
|
});
|
|
expect(estimated).toHaveLength(1);
|
|
expect(estimated[0]).toMatchObject({
|
|
value: 8_526,
|
|
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
|
|
syncVersion: 1,
|
|
});
|
|
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
const response = await POST(nativeStepsRequest(8_600));
|
|
expect(response.status).toBe(200);
|
|
|
|
const canonical = await prisma.measurement.findMany({
|
|
where: {
|
|
userId: TEST_USER_ID,
|
|
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
|
|
},
|
|
});
|
|
expect(canonical).toHaveLength(1);
|
|
expect(canonical[0]).toMatchObject({
|
|
value: 8_600,
|
|
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
|
syncVersion: 2,
|
|
});
|
|
});
|
|
|
|
it("keeps native statistics authoritative when XML arrives later", async () => {
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
expect((await POST(nativeStepsRequest(8_600))).status).toBe(200);
|
|
|
|
const result = await streamParseExportXml({
|
|
xmlPath: writeCumulativeXml(),
|
|
userId: TEST_USER_ID,
|
|
userTimezone: "Europe/Berlin",
|
|
prisma: getPrismaClient(),
|
|
});
|
|
expect(result.cumulativeEstimates).toEqual({ days: 1, rows: 1 });
|
|
expect(result.perType.ACTIVITY_STEPS).toMatchObject({
|
|
inserted: 0,
|
|
updated: 0,
|
|
});
|
|
|
|
const canonical = await getPrismaClient().measurement.findMany({
|
|
where: {
|
|
userId: TEST_USER_ID,
|
|
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
|
|
},
|
|
});
|
|
expect(canonical).toHaveLength(1);
|
|
expect(canonical[0]).toMatchObject({
|
|
value: 8_600,
|
|
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
|
syncVersion: 1,
|
|
});
|
|
});
|
|
|
|
it("re-imports an XML estimate onto one row without moving its syncVersion", async () => {
|
|
const prisma = getPrismaClient();
|
|
const xmlPath = writeCumulativeXml();
|
|
await streamParseExportXml({
|
|
xmlPath,
|
|
userId: TEST_USER_ID,
|
|
userTimezone: "Europe/Berlin",
|
|
prisma,
|
|
});
|
|
await streamParseExportXml({
|
|
xmlPath,
|
|
userId: TEST_USER_ID,
|
|
userTimezone: "Europe/Berlin",
|
|
prisma,
|
|
});
|
|
|
|
const rows = await prisma.measurement.findMany({
|
|
where: {
|
|
userId: TEST_USER_ID,
|
|
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
|
|
},
|
|
});
|
|
// The second import writes the same value, provenance and every other
|
|
// column, so the update changes nothing and migration 0412 keeps the
|
|
// row's stamps: the phone is not sent a row that did not change. The
|
|
// version never goes backwards; it stays where the first import left it.
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({
|
|
value: 8_526,
|
|
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
|
|
syncVersion: 1,
|
|
});
|
|
});
|
|
|
|
it("repairs an explicitly legacy aggregate with native statistics", async () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 15_608,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: new Date("2026-07-20T10:00:00.000Z"),
|
|
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
|
|
aggregationProvenance: "LEGACY_UNKNOWN",
|
|
},
|
|
});
|
|
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
expect((await POST(nativeStepsRequest(8_600))).status).toBe(200);
|
|
|
|
const repaired = await prisma.measurement.findMany({
|
|
where: {
|
|
userId: TEST_USER_ID,
|
|
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
|
|
},
|
|
});
|
|
expect(repaired).toHaveLength(1);
|
|
expect(repaired[0]).toMatchObject({
|
|
value: 8_600,
|
|
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
|
syncVersion: 2,
|
|
});
|
|
});
|
|
});
|
|
});
|