diff --git a/src/lib/apple-health/own-origin.ts b/src/lib/apple-health/own-origin.ts index 34f6f2f8c..1a60a05aa 100644 --- a/src/lib/apple-health/own-origin.ts +++ b/src/lib/apple-health/own-origin.ts @@ -4,9 +4,13 @@ * from the app, and any Withings, import or manual row the server-to-Health * mirror writes. Builds from before the marker existed wrote the id alone. * - * Such a sample is already in the database under its own source. When the same - * sample comes back through an Apple Health export it is a second copy of that - * reading, so the export import leaves it out. + * Such a sample is usually already in the database under its own source. When + * it comes back through an Apple Health export it is a second copy of that + * reading, so the export import leaves it out, but only once its + * `HKExternalUUID` names a row of the importing account (a cycle sample: once + * that account holds a day-log on its day). The marker alone says HealthLog + * wrote the sample, not that this instance still holds it: after a move to a + * new instance without a backup, the export is how those values come back. * * This is not the predicate the app's own sync uses. The app decides "our own * echo" from `HKExternalUUID` plus the sample's authoring source being the app diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index dc280d285..f75e2a53e 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -122,11 +122,14 @@ export interface ImportJobResult { clinical: { skipped: number }; /** * Records left out because HealthLog wrote them into Apple Health itself and - * already holds them under their own source, each counted once however often - * the export repeats it: `byMarker` carry the app's origin marker, - * `byExternalId` carry a HealthLog row id as `HKExternalUUID` (samples from - * before the marker existed), `matchedManual` match a manual entry (same type - * and value, within 2 s) without either. + * this account already holds them under their own source, each counted once + * however often the export repeats it: `byMarker` carry the app's origin + * marker and a HealthLog row id of this account (a cycle sample: a day-log of + * this account on its day), `byExternalId` carry such a row id without the + * marker (samples from before the marker existed), `matchedManual` match a + * manual entry (same type and value, within 2 s). A marked sample this + * account does not hold (a move to a new instance without a backup) is + * imported like any other. */ writtenByHealthLog: { byMarker: number; @@ -186,8 +189,11 @@ interface PreparedMeasurement { * at the close tag, once its `` children have been read. */ interface PendingRecord { - /** Queue the record; `healthLogId` is the `HKExternalUUID` it carried. */ - commit: (healthLogId: string | null) => void; + /** + * Queue the record; `healthLogId` is the `HKExternalUUID` it carried and + * `marked` whether it (or its correlation) carried the origin marker. + */ + commit: (healthLogId: string | null, marked: boolean) => void; ownOrigin: boolean; healthLogId: string | null; /** The record's sample identity, computed only when it is left out. */ @@ -369,7 +375,11 @@ export interface StreamParseInput { /** Prisma client to flush rows through. */ prisma: Pick< PrismaClient, - "measurement" | "workout" | "$transaction" | "$queryRawUnsafe" + | "measurement" + | "workout" + | "cycleDayLog" + | "$transaction" + | "$queryRawUnsafe" >; /** * Live progress hook. Called every `PROGRESS_TICK_RECORDS` records @@ -449,6 +459,15 @@ export async function streamParseExportXml( sampleKey: string; ownOrigin: boolean; } | null = null; + // Marked cycle samples, held to the end of the parse: one counts as written + // by HealthLog only when this account holds a day-log on its day. + const markedCycle: Array<{ + hkType: string; + dayKey: string; + rawValue: string | undefined; + protectionUsed?: boolean; + sampleKey: string; + }> = []; // Cumulative fold: type -> local day -> hashed source identity -> subtotal. const cumulativeBucket = new Map< @@ -471,13 +490,25 @@ export async function streamParseExportXml( countedOwn.add(sampleKey); writtenByHealthLog[reason] += 1; }; - // The `HKExternalUUID` a queued spot row carried, checked at flush. + // The `HKExternalUUID` a queued spot row carried, checked at flush, and the + // queued rows that carried the origin marker. const healthLogIdOf = new Map(); + const markedRows = new Set(); + // Marked cumulative samples that named a HealthLog row id, held to the end + // of the parse and added to their day only when the id is not this + // account's. Small: one per mirrored total. + const markedCumulative: Array<{ + type: MeasurementType; + healthLogId: string; + sampleKey: string; + add: () => void; + }> = []; // A `` (a blood pressure reading) wraps its records, and the // marker may sit on the correlation itself, before or after them. Its records // are held until it closes, then all kept or all left out together. let currentCorrelation: { ownOrigin: boolean; + healthLogId: string | null; held: PendingRecord[]; } | null = null; // Spot-row batch awaiting flush. @@ -536,12 +567,29 @@ export async function streamParseExportXml( }; // ── Flush helpers ────────────────────────────────────────── - // A sample the app wrote before it stamped its origin marker still carries - // the HealthLog row id as `HKExternalUUID`. A hit on one of this account's - // rows (deleted ones included: the sample is ours either way) of the same - // kind is an exact match. Blood pressure is one row per half, while the app - // stamps both samples of a reading with one id (the systolic row's, the - // diastolic row's in older builds), so either half matches either id. + // A sample the app wrote carries the HealthLog row id as `HKExternalUUID`, + // with the origin marker or (before the marker existed) without it. A hit on + // one of this account's rows (deleted ones included: the sample is ours + // either way) of the same kind is an exact match. The marker alone proves + // nothing about THIS account: after a move to a new instance without a + // backup, re-importing the export must bring the mirrored values back. + // Blood pressure is one row per half, while the app stamps both samples of a + // reading with one id (the systolic row's, the diastolic row's in older + // builds), so either half matches either id. + const ownRowKinds = async ( + ids: ReadonlySet, + ): Promise> => { + const out = new Map(); + const list = [...ids]; + for (let i = 0; i < list.length; i += 1000) { + const known = await prisma.measurement.findMany({ + where: { userId, id: { in: list.slice(i, i + 1000) } }, + select: { id: true, type: true }, + }); + for (const row of known) out.set(row.id, kindOf(row.type)); + } + return out; + }; const withoutHealthLogIds = async ( rows: PreparedMeasurement[], ): Promise => { @@ -551,17 +599,16 @@ export async function streamParseExportXml( if (id) ids.add(id); } if (ids.size === 0) return rows; - const known = await prisma.measurement.findMany({ - where: { userId, id: { in: [...ids] } }, - select: { id: true, type: true }, - }); - if (known.length === 0) return rows; - const typeOf = new Map(known.map((row) => [row.id, row.type])); + const kindOfId = await ownRowKinds(ids); + if (kindOfId.size === 0) return rows; return rows.filter((row) => { const id = healthLogIdOf.get(row); - const rowType = id ? typeOf.get(id) : undefined; - if (!rowType || kindOf(rowType) !== kindOf(row.type)) return true; - countOwn("byExternalId", row.externalId); + const rowKind = id ? kindOfId.get(id) : undefined; + if (!rowKind || rowKind !== kindOf(row.type)) return true; + countOwn( + markedRows.has(row) ? "byMarker" : "byExternalId", + row.externalId, + ); return false; }); }; @@ -663,7 +710,10 @@ export async function streamParseExportXml( const chunk = await withoutManualMirrors( await withoutHealthLogIds(incoming), ); - for (const row of incoming) healthLogIdOf.delete(row); + for (const row of incoming) { + healthLogIdOf.delete(row); + markedRows.delete(row); + } for (const row of chunk) widenSpan(row.measuredAt); const insertedArrivals: Array<{ id: string; @@ -993,6 +1043,61 @@ export async function streamParseExportXml( } }; + const consumeCycle = (rec: { + hkType: string; + dayKey: string; + rawValue: string | undefined; + protectionUsed?: boolean; + }): void => { + const consumed = cycleAccumulator.consume( + rec.hkType, + rec.dayKey, + rec.rawValue, + rec.protectionUsed, + ); + if (!consumed) { + // Recognised identifier but unrecognised value — count it under + // `unknown` with the reason tag so operators can spot it. + unknown[`${rec.hkType}::cycle_unmapped`] = + (unknown[`${rec.hkType}::cycle_unmapped`] ?? 0) + 1; + } + }; + + // Marked cumulative samples: left out when their id is a row of this + // account of the same kind, added to their day otherwise. + const settleMarkedCumulative = async (): Promise => { + if (markedCumulative.length === 0) return; + const kindOfId = await ownRowKinds( + new Set(markedCumulative.map((m) => m.healthLogId)), + ); + for (const m of markedCumulative.splice(0, markedCumulative.length)) { + if (kindOfId.get(m.healthLogId) === kindOf(m.type)) { + countOwn("byMarker", m.sampleKey); + } else { + m.add(); + } + } + }; + + // Marked cycle samples: left out when this account holds a day-log on + // their day (deleted ones included), folded in otherwise. + const settleMarkedCycle = async (): Promise => { + if (markedCycle.length === 0) return; + const days = [...new Set(markedCycle.map((rec) => rec.dayKey))]; + const held = new Set(); + for (let i = 0; i < days.length; i += 1000) { + const logs = await prisma.cycleDayLog.findMany({ + where: { userId, date: { in: days.slice(i, i + 1000) } }, + select: { date: true }, + }); + for (const log of logs) held.add(log.date); + } + for (const rec of markedCycle.splice(0, markedCycle.length)) { + if (held.has(rec.dayKey)) countOwn("byMarker", rec.sampleKey); + else consumeCycle(rec); + } + }; + // ── SAX parser configuration ──────────────────────────────── const parser = sax.parser(true, { trim: true }); let pendingError: Error | null = null; @@ -1121,27 +1226,44 @@ export async function streamParseExportXml( attrs.startDate, attrs.endDate, ), - commit: (healthLogId) => { + commit: (healthLogId, marked) => { if (CUMULATIVE_HK_TYPES.has(mapped.type)) { const dayKey = dayKeyForUserTz(mapped.takenAt, userTimezone); const sourceHash = hashCumulativeSourceIdentity( attrs.sourceName, attrs.device, ); - let byDay = cumulativeBucket.get(mapped.type); - if (!byDay) { - byDay = new Map(); - cumulativeBucket.set(mapped.type, byDay); + const add = () => { + let byDay = cumulativeBucket.get(mapped.type); + if (!byDay) { + byDay = new Map(); + cumulativeBucket.set(mapped.type, byDay); + } + let bySource = byDay.get(dayKey); + if (!bySource) { + bySource = new Map(); + byDay.set(dayKey, bySource); + } + bySource.set( + sourceHash, + (bySource.get(sourceHash) ?? 0) + mapped.value, + ); + }; + if (marked && healthLogId) { + markedCumulative.push({ + type: mapped.type, + healthLogId, + sampleKey: hashSampleKey( + hkType, + attrs.value ?? "", + attrs.startDate, + attrs.endDate, + ), + add, + }); + } else { + add(); } - let bySource = byDay.get(dayKey); - if (!bySource) { - bySource = new Map(); - byDay.set(dayKey, bySource); - } - bySource.set( - sourceHash, - (bySource.get(sourceHash) ?? 0) + mapped.value, - ); } else { // Spot row: derive a stable externalId, queue for flush. const externalId = hashSampleKey( @@ -1163,6 +1285,7 @@ export async function streamParseExportXml( }; spotBatch.push(row); if (healthLogId) healthLogIdOf.set(row, healthLogId); + if (marked) markedRows.add(row); } }, }; @@ -1244,7 +1367,7 @@ export async function streamParseExportXml( } if (name === "Correlation") { - currentCorrelation = { ownOrigin: false, held: [] }; + currentCorrelation = { ownOrigin: false, healthLogId: null, held: [] }; return; } @@ -1255,8 +1378,12 @@ export async function streamParseExportXml( else if (currentCycleRecord) currentCycleRecord.ownOrigin = true; else if (currentCorrelation) currentCorrelation.ownOrigin = true; } - if (pendingRecord && attrs.key === HK_EXTERNAL_UUID_METADATA_KEY) { - pendingRecord.healthLogId = attrs.value || null; + if (attrs.key === HK_EXTERNAL_UUID_METADATA_KEY) { + // On a record, or on the correlation that wraps it. + if (pendingRecord) pendingRecord.healthLogId = attrs.value || null; + else if (currentCorrelation && !currentCycleRecord) { + currentCorrelation.healthLogId = attrs.value || null; + } } // Attach the SexualActivity protection flag to the open cycle record. // Apple writes `HKMetadataKeySexualActivityProtectionUsed` with a @@ -1306,18 +1433,16 @@ export async function streamParseExportXml( const rec = pendingRecord; pendingRecord = null; if (currentCorrelation) currentCorrelation.held.push(rec); - else if (rec.ownOrigin) countOwn("byMarker", rec.sampleKey()); - else rec.commit(rec.healthLogId); + else rec.commit(rec.healthLogId, rec.ownOrigin); } if (tagName === "Correlation" && currentCorrelation) { const correlation = currentCorrelation; currentCorrelation = null; for (const rec of correlation.held) { - if (correlation.ownOrigin || rec.ownOrigin) { - countOwn("byMarker", rec.sampleKey()); - } else { - rec.commit(rec.healthLogId); - } + rec.commit( + rec.healthLogId ?? correlation.healthLogId, + correlation.ownOrigin || rec.ownOrigin, + ); } } if (tagName === "Workout" && currentWorkout) { @@ -1328,22 +1453,12 @@ export async function streamParseExportXml( const rec = currentCycleRecord; currentCycleRecord = null; if (rec.ownOrigin) { - // A day HealthLog mirrored into Apple Health from its own day-log. - countOwn("byMarker", rec.sampleKey); + // A day HealthLog mirrored into Apple Health from a day-log; decided + // at the end of the parse against this account's day-logs. + markedCycle.push(rec); return; } - const consumed = cycleAccumulator.consume( - rec.hkType, - rec.dayKey, - rec.rawValue, - rec.protectionUsed, - ); - if (!consumed) { - // Recognised identifier but unrecognised value — count it under - // `unknown` with the reason tag so operators can spot it. - unknown[`${rec.hkType}::cycle_unmapped`] = - (unknown[`${rec.hkType}::cycle_unmapped`] ?? 0) + 1; - } + consumeCycle(rec); } }; @@ -1421,7 +1536,9 @@ export async function streamParseExportXml( // threshold during the parse. await flushSpotBatch(); await flushWorkoutBatch(); + await settleMarkedCumulative(); await flushCumulativeBuckets(); + await settleMarkedCycle(); // v1.15.0 — fold the accumulated reproductive samples into CYCLE // day-logs. Gated on cycle-tracking being enabled for the account so a diff --git a/tests/fixtures/apple-health/export-own-origin.synthetic.xml b/tests/fixtures/apple-health/export-own-origin.synthetic.xml index aff5f87ac..fa9491018 100644 --- a/tests/fixtures/apple-health/export-own-origin.synthetic.xml +++ b/tests/fixtures/apple-health/export-own-origin.synthetic.xml @@ -9,9 +9,10 @@ verbatim; that has to be checked against a real export (see #1109). Records, in order: - 1. weight 80.1 carries the HealthLog origin marker -> left out + 1. weight 80.1 origin marker and a HealthLog row id -> left out + when the importing account holds that row 2. weight 80.2 user-entered, no marker -> imported - 3. weight 80.3 marker among other metadata entries -> left out + 3. weight 80.3 marker among other metadata entries, no id -> imported 4. weight 80.4 no metadata at all -> imported 5. weight 80.5 origin key with another value -> imported --> diff --git a/tests/integration/apple-health-import-own-samples.test.ts b/tests/integration/apple-health-import-own-samples.test.ts index a24b663de..e252a6fea 100644 --- a/tests/integration/apple-health-import-own-samples.test.ts +++ b/tests/integration/apple-health-import-own-samples.test.ts @@ -9,7 +9,12 @@ * twice and the rollups count it twice. * * Asserted against a real Postgres: - * - a record carrying the marker is left out, and counted; + * - a record carrying the marker and a row id of this account is left out, + * and counted; the marker alone proves nothing about this account (a move + * to a new instance without a backup), so a marked record whose id is + * missing, unknown or another account's is imported, a deleted row of this + * account still counts, and a marked cycle day counts only when the + * account holds a day-log on it; * - a record without it lands as before; * - the marker is read from a child `` that follows the * record's open tag, so the record is committed at its close tag; @@ -56,13 +61,46 @@ ${records} const weight = (value: number, end: string, children = "") => ` ${children}`; -const OWN = ``; +const MARKER = ``; +const ext = (id: string) => + ``; +/** The row the app mirrored: a weight of this account, its id on the sample. */ +const OWN_WEIGHT_ID = "own-weight-row"; +const OWN_SYS_ID = "own-sys-row"; +const OWN = `${MARKER}${ext(OWN_WEIGHT_ID)}`; +const OWN_BP = `${MARKER}${ext(OWN_SYS_ID)}`; const OTHER = ``; +/** + * A row of this account the samples point at. Withings and ten minutes off the + * samples, so neither the series under test nor the manual-value match sees it. + */ +async function seedOwnRow( + userId: string, + id: string, + type: "WEIGHT" | "BLOOD_PRESSURE_SYS" | "ACTIVITY_STEPS", + deletedAt: Date | null = null, +) { + await prisma.measurement.create({ + data: { + id, + userId, + type, + value: type === "ACTIVITY_STEPS" ? 1000 : 70, + unit: + type === "WEIGHT" ? "kg" : type === "ACTIVITY_STEPS" ? "steps" : "mmHg", + source: "WITHINGS", + measuredAt: new Date("2026-05-14T06:10:00.000Z"), + deletedAt, + }, + }); +} + async function run(records: string) { const user = await prisma.user.create({ data: { username: "own-samples", email: "own@example.test", role: "USER" }, }); + await seedOwnRow(user.id, OWN_WEIGHT_ID, "WEIGHT"); const result = await streamParseExportXml({ xmlPath: writeXml(records), userId: user.id, @@ -139,6 +177,7 @@ describe("blood pressure correlations and self-closing records", () => { const user = await prisma.user.create({ data: { username: "own-bp", email: "bp@example.test", role: "USER" }, }); + await seedOwnRow(user.id, OWN_SYS_ID, "BLOOD_PRESSURE_SYS"); const result = await streamParseExportXml({ xmlPath: writeXml(records), userId: user.id, @@ -148,6 +187,7 @@ describe("blood pressure correlations and self-closing records", () => { const rows = await prisma.measurement.findMany({ where: { userId: user.id, + source: "APPLE_HEALTH", type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] }, }, orderBy: [{ type: "asc" }], @@ -173,7 +213,7 @@ describe("blood pressure correlations and self-closing records", () => { [bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join( "\n", ), - `\n ${OWN}`, + `\n ${OWN_BP}`, ), ); expect(values).toEqual([]); @@ -186,7 +226,7 @@ describe("blood pressure correlations and self-closing records", () => { bpRecord("Diastolic", 79, AT), ].join("\n"); const { values } = await runBp( - ` \n${records}\n ${OWN}\n `, + ` \n${records}\n ${OWN_BP}\n `, ); expect(values).toEqual([]); }); @@ -195,7 +235,7 @@ describe("blood pressure correlations and self-closing records", () => { const { values } = await runBp( correlation( [ - bpRecord("Systolic", 121, AT, `\n ${OWN}\n `), + bpRecord("Systolic", 121, AT, `\n ${OWN_BP}\n `), bpRecord("Diastolic", 79, AT), ].join("\n"), ), @@ -210,7 +250,7 @@ describe("blood pressure correlations and self-closing records", () => { [bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join( "\n", ), - `\n ${OWN}`, + `\n ${OWN_BP}`, ), bpRecord("Systolic", 130, "2026-05-14 09:00:00 +0200"), ].join("\n"), @@ -230,10 +270,11 @@ describe("the synthetic export fixture", () => { // `export-own-origin.synthetic.xml` is invented, in the shape Apple's DTD // documents. It shows the parser reads a file laid out like an export; it // cannot show that Apple writes a custom metadata key verbatim. - it("leaves out the two marked records and imports the other three", async () => { + it("leaves out the marked record this account holds and imports the other four", async () => { const user = await prisma.user.create({ data: { username: "own-fixture", email: "fx@example.test", role: "USER" }, }); + await seedOwnRow(user.id, "00000000-0000-0000-0000-000000000001", "WEIGHT"); const result = await streamParseExportXml({ xmlPath: join( process.cwd(), @@ -247,9 +288,9 @@ describe("the synthetic export fixture", () => { where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" }, orderBy: { measuredAt: "asc" }, }); - expect(rows.map((r) => r.value)).toEqual([80.2, 80.4, 80.5]); + expect(rows.map((r) => r.value)).toEqual([80.2, 80.3, 80.4, 80.5]); expect(result.writtenByHealthLog).toEqual({ - byMarker: 2, + byMarker: 1, byExternalId: 0, matchedManual: 0, }); @@ -421,8 +462,6 @@ describe("the manual-mirror lookup stays off the hot path", () => { describe("a sample whose HKExternalUUID is a HealthLog row id", () => { const AT = "2026-05-14 08:00:00 +0200"; - const ext = (id: string) => - ``; async function runWith( seed: (userId: string) => Promise>, @@ -524,11 +563,12 @@ describe("a sample whose HKExternalUUID is a HealthLog row id", () => { describe("each sample left out is counted once", () => { it("does not count a marked record twice when it appears top level and inside its correlation", async () => { const AT = "2026-05-14 08:00:00 +0200"; - const sys = bpRecord("Systolic", 121, AT, `\n ${OWN}\n `); - const dia = bpRecord("Diastolic", 79, AT, `\n ${OWN}\n `); + const sys = bpRecord("Systolic", 121, AT, `\n ${OWN_BP}\n `); + const dia = bpRecord("Diastolic", 79, AT, `\n ${OWN_BP}\n `); const user = await prisma.user.create({ data: { username: "own-once", email: "once@example.test", role: "USER" }, }); + await seedOwnRow(user.id, OWN_SYS_ID, "BLOOD_PRESSURE_SYS"); const result = await streamParseExportXml({ xmlPath: writeXml( [sys, dia, correlation([sys, dia].join("\n"))].join("\n"), @@ -542,7 +582,7 @@ describe("each sample left out is counted once", () => { }); describe("a cycle sample HealthLog wrote itself", () => { - it("is left out of the day-log import", async () => { + it("is left out on a day this account holds, imported on a day it does not", async () => { const user = await prisma.user.create({ data: { username: "own-cycle", @@ -552,11 +592,20 @@ describe("a cycle sample HealthLog wrote itself", () => { timezone: "Europe/Berlin", }, }); + // The day the app mirrored from: a day-log of this account. + await prisma.cycleDayLog.create({ + data: { userId: user.id, date: "2026-03-02", flow: "MEDIUM" }, + }); const flow = (day: string, children = "") => ` ${children}`; const result = await streamParseExportXml({ xmlPath: writeXml( - [flow("2026-03-02", OWN), flow("2026-03-03")].join("\n"), + [ + flow("2026-03-02", MARKER), + flow("2026-03-03"), + // Marked, but no day-log here: a move to a new instance. + flow("2026-03-04", MARKER), + ].join("\n"), ), userId: user.id, userTimezone: "Europe/Berlin", @@ -566,7 +615,119 @@ describe("a cycle sample HealthLog wrote itself", () => { where: { userId: user.id }, orderBy: { date: "asc" }, }); - expect(days.map((d) => d.date)).toEqual(["2026-03-03"]); + expect(days.map((d) => d.date)).toEqual([ + "2026-03-02", + "2026-03-03", + "2026-03-04", + ]); + // The held day stays the account's own: the marked sample did not + // re-import it as an Apple Health day. + expect(days[0].source).toBe("MANUAL"); + expect(result.writtenByHealthLog.byMarker).toBe(1); + }); +}); + +describe("a marked record this account does not hold", () => { + // A move to a new instance without a backup: the export still carries the + // marker on every value the app mirrored, but none of the rows exist here. + it("is imported when the id is missing, unknown or another account's", async () => { + const other = await prisma.user.create({ + data: { username: "own-move-2", email: "mv2@example.test", role: "USER" }, + }); + await seedOwnRow(other.id, "foreign-weight-row", "WEIGHT"); + const user = await prisma.user.create({ + data: { username: "own-move", email: "mv@example.test", role: "USER" }, + }); + const result = await streamParseExportXml({ + xmlPath: writeXml( + [ + weight(80.1, "2026-05-14 08:00:00 +0200", MARKER), + weight( + 80.2, + "2026-05-14 09:00:00 +0200", + `${MARKER}${ext("00000000-0000-4000-8000-000000000009")}`, + ), + weight( + 80.3, + "2026-05-14 10:00:00 +0200", + `${MARKER}${ext("foreign-weight-row")}`, + ), + ].join("\n"), + ), + userId: user.id, + userTimezone: "Europe/Berlin", + prisma, + }); + const rows = await prisma.measurement.findMany({ + where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" }, + orderBy: { measuredAt: "asc" }, + }); + expect(rows.map((r) => r.value)).toEqual([80.1, 80.2, 80.3]); + expect(result.writtenByHealthLog).toEqual({ + byMarker: 0, + byExternalId: 0, + matchedManual: 0, + }); + }); + + it("is still left out when its id names a deleted row of this account", async () => { + const user = await prisma.user.create({ + data: { + username: "own-deleted", + email: "del@example.test", + role: "USER", + }, + }); + await seedOwnRow(user.id, OWN_WEIGHT_ID, "WEIGHT", new Date()); + const result = await streamParseExportXml({ + xmlPath: writeXml(weight(80.1, "2026-05-14 08:00:00 +0200", OWN)), + userId: user.id, + userTimezone: "Europe/Berlin", + prisma, + }); + const rows = await prisma.measurement.findMany({ + where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" }, + }); + expect(rows).toHaveLength(0); + expect(result.writtenByHealthLog.byMarker).toBe(1); + }); + + it("adds a marked cumulative sample to its day unless its id is this account's", async () => { + const user = await prisma.user.create({ + data: { username: "own-steps", email: "st@example.test", role: "USER" }, + }); + await seedOwnRow(user.id, "own-steps-row", "ACTIVITY_STEPS"); + const steps = (value: number, at: string, children: string) => + ` ${children}`; + const result = await streamParseExportXml({ + xmlPath: writeXml( + [ + steps( + 700, + "2026-05-14 08:00:00 +0200", + `${MARKER}${ext("own-steps-row")}`, + ), + steps( + 300, + "2026-05-14 09:00:00 +0200", + `${MARKER}${ext("not-a-row")}`, + ), + steps(200, "2026-05-14 10:00:00 +0200", ""), + ].join("\n"), + ), + userId: user.id, + userTimezone: "Europe/Berlin", + prisma, + }); + const rows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "ACTIVITY_STEPS", + source: "APPLE_HEALTH", + }, + }); + // 300 + 200: the 700 the app mirrored from this account's row stays out. + expect(rows.map((r) => r.value)).toEqual([500]); expect(result.writtenByHealthLog.byMarker).toBe(1); }); });