From 29620ffa497dcbcf193ec2faeb26aa80963d4c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 14:40:37 +0200 Subject: [PATCH] test(sync): pin the delta feed before moving its domain pages --- .../sync-changes.01-first-page.json.snap | 230 ++++++++++++ .../sync-changes.02-second-page.json.snap | 102 +++++ .../sync-changes.03-the-rest.json.snap | 72 ++++ .../sync-changes.04-caught-up.json.snap | 59 +++ .../sync-changes.05-past-retention.json.snap | 49 +++ .../sync-changes.06-before-reset.json.snap | 49 +++ .../sync-changes.07-cycle-off.json.snap | 191 ++++++++++ .../sync-changes.08-invalid.json.snap | 29 ++ .../routes/parity/sync-changes.parity.test.ts | 355 ++++++++++++++++++ 9 files changed, 1136 insertions(+) create mode 100644 tests/integration/routes/parity/__snapshots__/sync-changes.01-first-page.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/sync-changes.02-second-page.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/sync-changes.03-the-rest.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/sync-changes.04-caught-up.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/sync-changes.05-past-retention.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/sync-changes.06-before-reset.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/sync-changes.07-cycle-off.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/sync-changes.08-invalid.json.snap create mode 100644 tests/integration/routes/parity/sync-changes.parity.test.ts diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.01-first-page.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.01-first-page.json.snap new file mode 100644 index 000000000..cac388d58 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.01-first-page.json.snap @@ -0,0 +1,230 @@ +{ + "status": 200, + "body": { + "data": { + "serverNow": "", + "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDQ4MjAwMDAwLCJpIjoibS0wMiJ9LCJtb29kIjp7InUiOjE3OTIwNTE4MDAwMDAsImkiOiJtb29kLTAyIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzAwMDAwMCwiaSI6ImluLTAyIn0sImN5Y2xlRGF5cyI6eyJ1IjoxNzkyMDU2NjAwMDAwLCJpIjoiY2QtMDIifSwiY3ljbGVzIjp7InUiOjE3OTIwNTQ4MDAwMDAsImkiOiJjeS0wMiJ9fX0", + "hasMore": true, + "cursorExpired": false, + "changes": { + "measurements": { + "upserts": [ + { + "id": "m-01", + "externalId": null, + "type": "WEIGHT", + "value": 81.2, + "unit": "kg", + "measuredAt": "2026-10-14T06:30:00.000Z", + "source": "MANUAL", + "notes": "after the run", + "syncVersion": "", + "updatedAt": "" + }, + { + "id": "m-02", + "externalId": "w-pulse-1", + "type": "PULSE", + "value": 61, + "unit": "bpm", + "measuredAt": "2026-10-14T07:00:00.000Z", + "source": "WITHINGS", + "notes": null, + "syncVersion": "", + "updatedAt": "" + } + ], + "tombstones": [] + }, + "mood": { + "upserts": [ + { + "id": "mood-01", + "date": "2026-10-14", + "mood": "GUT", + "score": 4, + "a1": 7, + "a2": 3, + "a3": null, + "a4": null, + "a5": null, + "context": { + "workStatus": "regular", + "workMinutes": 480, + "overtimeMinutes": null, + "workLoad": null, + "workSatisfaction": null, + "contactCircles": [], + "contactForm": null, + "contactExtent": null, + "contactQuality": null, + "contactSupport": null, + "leisureCategories": [], + "leisureMinutes": null, + "leisureJoy": null, + "leisureRecovery": null, + "eventType": null, + "eventValence": null, + "eventAt": null, + "note": null + }, + "tags": "[\"slept well\"]", + "note": "a calm morning", + "moodLoggedAt": "2026-10-14T07:00:00.000Z", + "source": "MANUAL", + "syncVersion": "", + "updatedAt": "" + } + ], + "tombstones": [ + { + "id": "mood-02", + "syncVersion": "", + "deletedAt": "2026-10-15T11:30:00.000Z", + "updatedAt": "" + } + ] + }, + "intakes": { + "upserts": [ + { + "id": "in-01", + "medicationId": "", + "scheduledFor": "2026-10-14T06:00:00.000Z", + "takenAt": "2026-10-14T06:10:00.000Z", + "skipped": false, + "source": "WEB", + "autoRecorded": false, + "syncVersion": "", + "updatedAt": "" + }, + { + "id": "in-02", + "medicationId": "", + "scheduledFor": "2026-10-13T06:00:00.000Z", + "takenAt": "2026-10-13T06:00:00.000Z", + "skipped": false, + "source": "WEB", + "autoRecorded": true, + "syncVersion": "", + "updatedAt": "" + } + ], + "tombstones": [] + }, + "cycleDays": { + "upserts": [ + { + "id": "cd-01", + "date": "2026-10-10", + "cycleId": "cy-02", + "flow": "MEDIUM", + "intermenstrualBleeding": false, + "basalBodyTempC": null, + "temperatureExcluded": false, + "ovulationTest": null, + "cervicalMucus": null, + "cervixPosition": null, + "cervixFirmness": null, + "cervixOpening": null, + "sexualActivity": false, + "protectedSex": null, + "pregnancyTest": null, + "progesteroneTest": null, + "contraceptive": null, + "symptoms": [], + "note": null, + "source": "MANUAL", + "externalId": null, + "syncVersion": "", + "updatedAt": "", + "deletedAt": null + }, + { + "id": "cd-02", + "date": "2026-10-11", + "cycleId": "cy-02", + "flow": "LIGHT", + "intermenstrualBleeding": false, + "basalBodyTempC": 36.4, + "temperatureExcluded": false, + "ovulationTest": null, + "cervicalMucus": null, + "cervixPosition": null, + "cervixFirmness": null, + "cervixOpening": null, + "sexualActivity": false, + "protectedSex": null, + "pregnancyTest": null, + "progesteroneTest": null, + "contraceptive": null, + "symptoms": [], + "note": null, + "source": "MANUAL", + "externalId": "hk-cycle-1", + "syncVersion": "", + "updatedAt": "", + "deletedAt": null + } + ], + "tombstones": [] + }, + "cycles": { + "upserts": [ + { + "id": "cy-01", + "startDate": "2026-09-12", + "endDate": "2026-10-09", + "periodEndDate": "2026-09-16", + "lengthDays": 28, + "ovulationDate": null, + "ovulationConfirmed": false, + "isPredicted": false, + "syncVersion": "", + "updatedAt": "" + }, + { + "id": "cy-02", + "startDate": "2026-10-10", + "endDate": null, + "periodEndDate": null, + "lengthDays": null, + "ovulationDate": null, + "ovulationConfirmed": false, + "isPredicted": false, + "syncVersion": "", + "updatedAt": "" + } + ], + "tombstones": [] + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "sync.changes.pull" + }, + "meta": { + "measurement_upserts": 2, + "measurement_tombstones": 0, + "mood_upserts": 1, + "mood_tombstones": 1, + "intake_upserts": 2, + "intake_tombstones": 0, + "cycle_day_upserts": 2, + "cycle_day_tombstones": 0, + "cycle_upserts": 2, + "cycle_tombstones": 0, + "cycle_module_enabled": true, + "has_more": true, + "cursor_present": false + }, + "warnings": null, + "error": null + } + ] +} diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.02-second-page.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.02-second-page.json.snap new file mode 100644 index 000000000..96ebe0b69 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.02-second-page.json.snap @@ -0,0 +1,102 @@ +{ + "status": 200, + "body": { + "data": { + "serverNow": "", + "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDQ5NDAwMDAwLCJpIjoibS0wNCJ9LCJtb29kIjp7InUiOjE3OTIwNTE4MDAwMDAsImkiOiJtb29kLTAyIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzYwMDAwMCwiaSI6ImluLTAzIn0sImN5Y2xlRGF5cyI6eyJ1IjoxNzkyMDU3MjAwMDAwLCJpIjoiY2QtMDMifSwiY3ljbGVzIjp7InUiOjE3OTIwNTU0MDAwMDAsImkiOiJjeS0wMyJ9fX0", + "hasMore": true, + "cursorExpired": false, + "changes": { + "measurements": { + "upserts": [ + { + "id": "m-04", + "externalId": "stats:HKQuantityTypeIdentifierHeartRate:2026-06-17T09", + "type": "PULSE", + "value": 64, + "unit": "bpm", + "measuredAt": "2026-06-17T09:30:00.000Z", + "source": "APPLE_HEALTH", + "notes": null, + "syncVersion": "", + "updatedAt": "" + } + ], + "tombstones": [ + { + "id": "m-03", + "externalId": "w-weight-2", + "syncVersion": "", + "deletedAt": "2026-10-15T11:30:00.000Z", + "updatedAt": "" + } + ] + }, + "mood": { + "upserts": [], + "tombstones": [] + }, + "intakes": { + "upserts": [], + "tombstones": [ + { + "id": "in-03", + "syncVersion": "", + "deletedAt": "2026-10-15T11:30:00.000Z", + "updatedAt": "" + } + ] + }, + "cycleDays": { + "upserts": [], + "tombstones": [ + { + "id": "cd-03", + "externalId": "hk-cycle-0", + "syncVersion": "", + "deletedAt": "2026-10-15T11:30:00.000Z", + "updatedAt": "" + } + ] + }, + "cycles": { + "upserts": [], + "tombstones": [ + { + "id": "cy-03", + "syncVersion": "", + "deletedAt": "2026-10-15T11:30:00.000Z", + "updatedAt": "" + } + ] + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "sync.changes.pull" + }, + "meta": { + "measurement_upserts": 1, + "measurement_tombstones": 1, + "mood_upserts": 0, + "mood_tombstones": 0, + "intake_upserts": 0, + "intake_tombstones": 1, + "cycle_day_upserts": 0, + "cycle_day_tombstones": 1, + "cycle_upserts": 0, + "cycle_tombstones": 1, + "cycle_module_enabled": true, + "has_more": true, + "cursor_present": true + }, + "warnings": null, + "error": null + } + ] +} diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.03-the-rest.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.03-the-rest.json.snap new file mode 100644 index 000000000..88e5797a6 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.03-the-rest.json.snap @@ -0,0 +1,72 @@ +{ + "status": 200, + "body": { + "data": { + "serverNow": "", + "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDUwNjAwMDAwLCJpIjoibS0wNiJ9LCJtb29kIjp7InUiOjE3OTIwNTE4MDAwMDAsImkiOiJtb29kLTAyIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzYwMDAwMCwiaSI6ImluLTAzIn0sImN5Y2xlRGF5cyI6eyJ1IjoxNzkyMDU3MjAwMDAwLCJpIjoiY2QtMDMifSwiY3ljbGVzIjp7InUiOjE3OTIwNTU0MDAwMDAsImkiOiJjeS0wMyJ9fX0", + "hasMore": false, + "cursorExpired": false, + "changes": { + "measurements": { + "upserts": [ + { + "id": "m-06", + "externalId": null, + "type": "BLOOD_PRESSURE_SYS", + "value": 128, + "unit": "mmHg", + "measuredAt": "2026-10-14T08:00:00.000Z", + "source": "MANUAL", + "notes": null, + "syncVersion": "", + "updatedAt": "" + } + ], + "tombstones": [] + }, + "mood": { + "upserts": [], + "tombstones": [] + }, + "intakes": { + "upserts": [], + "tombstones": [] + }, + "cycleDays": { + "upserts": [], + "tombstones": [] + }, + "cycles": { + "upserts": [], + "tombstones": [] + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "sync.changes.pull" + }, + "meta": { + "measurement_upserts": 1, + "measurement_tombstones": 0, + "mood_upserts": 0, + "mood_tombstones": 0, + "intake_upserts": 0, + "intake_tombstones": 0, + "cycle_day_upserts": 0, + "cycle_day_tombstones": 0, + "cycle_upserts": 0, + "cycle_tombstones": 0, + "cycle_module_enabled": true, + "has_more": false, + "cursor_present": true + }, + "warnings": null, + "error": null + } + ] +} diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.04-caught-up.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.04-caught-up.json.snap new file mode 100644 index 000000000..d50f7efae --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.04-caught-up.json.snap @@ -0,0 +1,59 @@ +{ + "status": 200, + "body": { + "data": { + "serverNow": "", + "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDUwNjAwMDAwLCJpIjoibS0wNiJ9LCJtb29kIjp7InUiOjE3OTIwNTE4MDAwMDAsImkiOiJtb29kLTAyIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzYwMDAwMCwiaSI6ImluLTAzIn0sImN5Y2xlRGF5cyI6eyJ1IjoxNzkyMDU3MjAwMDAwLCJpIjoiY2QtMDMifSwiY3ljbGVzIjp7InUiOjE3OTIwNTU0MDAwMDAsImkiOiJjeS0wMyJ9fX0", + "hasMore": false, + "cursorExpired": false, + "changes": { + "measurements": { + "upserts": [], + "tombstones": [] + }, + "mood": { + "upserts": [], + "tombstones": [] + }, + "intakes": { + "upserts": [], + "tombstones": [] + }, + "cycleDays": { + "upserts": [], + "tombstones": [] + }, + "cycles": { + "upserts": [], + "tombstones": [] + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "sync.changes.pull" + }, + "meta": { + "measurement_upserts": 0, + "measurement_tombstones": 0, + "mood_upserts": 0, + "mood_tombstones": 0, + "intake_upserts": 0, + "intake_tombstones": 0, + "cycle_day_upserts": 0, + "cycle_day_tombstones": 0, + "cycle_upserts": 0, + "cycle_tombstones": 0, + "cycle_module_enabled": true, + "has_more": false, + "cursor_present": true + }, + "warnings": null, + "error": null + } + ] +} diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.05-past-retention.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.05-past-retention.json.snap new file mode 100644 index 000000000..06894579d --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.05-past-retention.json.snap @@ -0,0 +1,49 @@ +{ + "status": 200, + "body": { + "data": { + "serverNow": "", + "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzc0Nzg1NjAwMDAwLCJpIjoibS0wMSJ9fX0", + "hasMore": false, + "cursorExpired": true, + "changes": { + "measurements": { + "upserts": [], + "tombstones": [] + }, + "mood": { + "upserts": [], + "tombstones": [] + }, + "intakes": { + "upserts": [], + "tombstones": [] + }, + "cycleDays": { + "upserts": [], + "tombstones": [] + }, + "cycles": { + "upserts": [], + "tombstones": [] + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "sync.changes.pull" + }, + "meta": { + "cursor_expired": true, + "cursor_expired_reason": "retention", + "returned": 0 + }, + "warnings": null, + "error": null + } + ] +} diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.06-before-reset.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.06-before-reset.json.snap new file mode 100644 index 000000000..360c66c24 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.06-before-reset.json.snap @@ -0,0 +1,49 @@ +{ + "status": 200, + "body": { + "data": { + "serverNow": "", + "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDQ4MjAwMDAwLCJpIjoibS0wMiJ9LCJtb29kIjp7InUiOjE3OTIwNTE4MDAwMDAsImkiOiJtb29kLTAyIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzAwMDAwMCwiaSI6ImluLTAyIn0sImN5Y2xlRGF5cyI6eyJ1IjoxNzkyMDU2NjAwMDAwLCJpIjoiY2QtMDIifSwiY3ljbGVzIjp7InUiOjE3OTIwNTQ4MDAwMDAsImkiOiJjeS0wMiJ9fX0", + "hasMore": false, + "cursorExpired": true, + "changes": { + "measurements": { + "upserts": [], + "tombstones": [] + }, + "mood": { + "upserts": [], + "tombstones": [] + }, + "intakes": { + "upserts": [], + "tombstones": [] + }, + "cycleDays": { + "upserts": [], + "tombstones": [] + }, + "cycles": { + "upserts": [], + "tombstones": [] + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "sync.changes.pull" + }, + "meta": { + "cursor_expired": true, + "cursor_expired_reason": "sync_reset", + "returned": 0 + }, + "warnings": null, + "error": null + } + ] +} diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.07-cycle-off.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.07-cycle-off.json.snap new file mode 100644 index 000000000..e26c893fc --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.07-cycle-off.json.snap @@ -0,0 +1,191 @@ +{ + "status": 200, + "body": { + "data": { + "serverNow": "", + "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDUwNjAwMDAwLCJpIjoibS0wNiJ9LCJtb29kIjp7InUiOjE3OTIwNTE4MDAwMDAsImkiOiJtb29kLTAyIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzYwMDAwMCwiaSI6ImluLTAzIn19fQ", + "hasMore": false, + "cursorExpired": false, + "changes": { + "measurements": { + "upserts": [ + { + "id": "m-01", + "externalId": null, + "type": "WEIGHT", + "value": 81.2, + "unit": "kg", + "measuredAt": "2026-10-14T06:30:00.000Z", + "source": "MANUAL", + "notes": "after the run", + "syncVersion": "", + "updatedAt": "" + }, + { + "id": "m-02", + "externalId": "w-pulse-1", + "type": "PULSE", + "value": 61, + "unit": "bpm", + "measuredAt": "2026-10-14T07:00:00.000Z", + "source": "WITHINGS", + "notes": null, + "syncVersion": "", + "updatedAt": "" + }, + { + "id": "m-04", + "externalId": "stats:HKQuantityTypeIdentifierHeartRate:2026-06-17T09", + "type": "PULSE", + "value": 64, + "unit": "bpm", + "measuredAt": "2026-06-17T09:30:00.000Z", + "source": "APPLE_HEALTH", + "notes": null, + "syncVersion": "", + "updatedAt": "" + }, + { + "id": "m-06", + "externalId": null, + "type": "BLOOD_PRESSURE_SYS", + "value": 128, + "unit": "mmHg", + "measuredAt": "2026-10-14T08:00:00.000Z", + "source": "MANUAL", + "notes": null, + "syncVersion": "", + "updatedAt": "" + } + ], + "tombstones": [ + { + "id": "m-03", + "externalId": "w-weight-2", + "syncVersion": "", + "deletedAt": "2026-10-15T11:30:00.000Z", + "updatedAt": "" + } + ] + }, + "mood": { + "upserts": [ + { + "id": "mood-01", + "date": "2026-10-14", + "mood": "GUT", + "score": 4, + "a1": 7, + "a2": 3, + "a3": null, + "a4": null, + "a5": null, + "context": { + "workStatus": "regular", + "workMinutes": 480, + "overtimeMinutes": null, + "workLoad": null, + "workSatisfaction": null, + "contactCircles": [], + "contactForm": null, + "contactExtent": null, + "contactQuality": null, + "contactSupport": null, + "leisureCategories": [], + "leisureMinutes": null, + "leisureJoy": null, + "leisureRecovery": null, + "eventType": null, + "eventValence": null, + "eventAt": null, + "note": null + }, + "tags": "[\"slept well\"]", + "note": "a calm morning", + "moodLoggedAt": "2026-10-14T07:00:00.000Z", + "source": "MANUAL", + "syncVersion": "", + "updatedAt": "" + } + ], + "tombstones": [ + { + "id": "mood-02", + "syncVersion": "", + "deletedAt": "2026-10-15T11:30:00.000Z", + "updatedAt": "" + } + ] + }, + "intakes": { + "upserts": [ + { + "id": "in-01", + "medicationId": "", + "scheduledFor": "2026-10-14T06:00:00.000Z", + "takenAt": "2026-10-14T06:10:00.000Z", + "skipped": false, + "source": "WEB", + "autoRecorded": false, + "syncVersion": "", + "updatedAt": "" + }, + { + "id": "in-02", + "medicationId": "", + "scheduledFor": "2026-10-13T06:00:00.000Z", + "takenAt": "2026-10-13T06:00:00.000Z", + "skipped": false, + "source": "WEB", + "autoRecorded": true, + "syncVersion": "", + "updatedAt": "" + } + ], + "tombstones": [ + { + "id": "in-03", + "syncVersion": "", + "deletedAt": "2026-10-15T11:30:00.000Z", + "updatedAt": "" + } + ] + }, + "cycleDays": { + "upserts": [], + "tombstones": [] + }, + "cycles": { + "upserts": [], + "tombstones": [] + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "sync.changes.pull" + }, + "meta": { + "measurement_upserts": 4, + "measurement_tombstones": 1, + "mood_upserts": 1, + "mood_tombstones": 1, + "intake_upserts": 2, + "intake_tombstones": 1, + "cycle_day_upserts": 0, + "cycle_day_tombstones": 0, + "cycle_upserts": 0, + "cycle_tombstones": 0, + "cycle_module_enabled": false, + "has_more": false, + "cursor_present": false + }, + "warnings": null, + "error": null + } + ] +} diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.08-invalid.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.08-invalid.json.snap new file mode 100644 index 000000000..e36ea56fb --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.08-invalid.json.snap @@ -0,0 +1,29 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "Invalid sync query", + "details": { + "issues": [ + { + "path": "limit", + "code": "too_small", + "message": "Too small: expected number to be >=1" + } + ] + }, + "meta": { + "errorCode": "http.invalid", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": null, + "meta": null, + "warnings": null, + "error": null + } + ] +} diff --git a/tests/integration/routes/parity/sync-changes.parity.test.ts b/tests/integration/routes/parity/sync-changes.parity.test.ts new file mode 100644 index 000000000..d4d092ff9 --- /dev/null +++ b/tests/integration/routes/parity/sync-changes.parity.test.ts @@ -0,0 +1,355 @@ +/** + * Parity for `GET /api/sync/changes` while its domain pages move into + * `src/lib/sync/`. + * + * One account holds live and deleted rows in all five domains (a sealed note + * on a measurement and a mood entry, a mood context, a recorded and an + * auto-recorded intake, cycle days and cycles), plus a raw sample that a live + * hourly row folded away. The walk drains the feed in small pages to the + * end, pulls once caught up, then asks with a cursor past retention, a + * cursor from before a reset, the cycle module switched off, and a refused + * query. Every row carries a fixed id and `updatedAt`, so the keyset order + * and the opaque cursor are the same on every run. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { GET } from "@/app/api/sync/changes/route"; +import { encryptNote } from "@/lib/crypto/note-cipher"; +import { encodeCursor } from "@/lib/sync/cursor"; + +import { callRoute, type RouteAuth } from "../../../kit/call-route"; +import { makeMedication, makeSession, makeUser } from "../../../kit/factories"; +import { + eventFacts, + expectParity, + stable, + wideEventRecorder, +} from "../../../kit/parity"; +import { getPrismaClient, truncateAllTables } from "../../setup"; + +vi.mock("@/lib/logging/transports", async (importOriginal) => { + const actual = + await importOriginal(); + const { wideEventRecorder: recorder } = await import("../../../kit/parity"); + return { + ...actual, + emitIfSampled: (event: (typeof recorder.events)[number]) => { + recorder.events.push(event); + }, + }; +}); + +const prisma = getPrismaClient(); +const NOW = new Date("2026-10-15T12:00:00.000Z"); +const MIN = 60_000; +const DAY = 86_400_000; + +let userId: string; +let owner: RouteAuth; + +/** An `updatedAt` some minutes before the clock, one per row. */ +function touched(minutesAgo: number): Date { + return new Date(NOW.getTime() - minutesAgo * MIN); +} + +async function seed(): Promise { + const deletedAt = touched(30); + await prisma.measurement.createMany({ + data: [ + { + id: "m-01", + userId, + type: "WEIGHT", + value: 81.2, + unit: "kg", + measuredAt: new Date("2026-10-14T06:30:00.000Z"), + source: "MANUAL", + notesEncrypted: encryptNote("after the run"), + updatedAt: touched(300), + }, + { + id: "m-02", + userId, + type: "PULSE", + value: 61, + unit: "bpm", + measuredAt: new Date("2026-10-14T07:00:00.000Z"), + source: "WITHINGS", + externalId: "w-pulse-1", + updatedAt: touched(290), + }, + { + id: "m-03", + userId, + type: "WEIGHT", + value: 81.6, + unit: "kg", + measuredAt: new Date("2026-10-13T06:30:00.000Z"), + source: "WITHINGS", + externalId: "w-weight-2", + deletedAt, + updatedAt: touched(280), + }, + // The hourly row and a raw sample it folded away, four months back: + // the sample's tombstone is the fold's, not a deletion anyone made. + { + id: "m-04", + userId, + type: "PULSE", + value: 64, + unit: "bpm", + measuredAt: new Date("2026-06-17T09:30:00.000Z"), + source: "APPLE_HEALTH", + externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-06-17T09", + updatedAt: touched(270), + }, + { + id: "m-05", + userId, + type: "PULSE", + value: 66, + unit: "bpm", + measuredAt: new Date("2026-06-17T09:12:00.000Z"), + source: "APPLE_HEALTH", + externalId: "uuid-raw-pulse-1", + deletedAt: touched(260), + updatedAt: touched(260), + }, + { + id: "m-06", + userId, + type: "BLOOD_PRESSURE_SYS", + value: 128, + unit: "mmHg", + measuredAt: new Date("2026-10-14T08:00:00.000Z"), + source: "MANUAL", + updatedAt: touched(250), + }, + ], + }); + await prisma.moodEntry.createMany({ + data: [ + { + id: "mood-01", + userId, + date: "2026-10-14", + tz: "Europe/Berlin", + mood: "GUT", + score: 4, + moodA1: 7, + stressA2: 3, + tags: JSON.stringify(["slept well"]), + noteEncrypted: encryptNote("a calm morning"), + moodLoggedAt: new Date("2026-10-14T07:00:00.000Z"), + source: "MANUAL", + updatedAt: touched(240), + }, + { + id: "mood-02", + userId, + date: "2026-10-13", + tz: "Europe/Berlin", + mood: "SCHLECHT", + score: 2, + moodA1: 3, + moodLoggedAt: new Date("2026-10-13T20:00:00.000Z"), + source: "MANUAL", + deletedAt, + updatedAt: touched(230), + }, + ], + }); + await prisma.moodContext.create({ + data: { + moodEntryId: "mood-01", + userId, + workStatus: "regular", + workMinutes: 480, + }, + }); + const medication = await makeMedication({ id: userId }); + await prisma.medicationIntakeEvent.createMany({ + data: [ + { + id: "in-01", + userId, + medicationId: medication.id, + scheduledFor: new Date("2026-10-14T06:00:00.000Z"), + takenAt: new Date("2026-10-14T06:10:00.000Z"), + source: "WEB", + updatedAt: touched(220), + }, + { + id: "in-02", + userId, + medicationId: medication.id, + scheduledFor: new Date("2026-10-13T06:00:00.000Z"), + takenAt: new Date("2026-10-13T06:00:00.000Z"), + autoRecorded: true, + source: "WEB", + updatedAt: touched(210), + }, + { + id: "in-03", + userId, + medicationId: medication.id, + scheduledFor: new Date("2026-10-12T06:00:00.000Z"), + skipped: true, + source: "WEB", + deletedAt, + updatedAt: touched(200), + }, + ], + }); + await prisma.menstrualCycle.createMany({ + data: [ + { + id: "cy-01", + userId, + startDate: "2026-09-12", + endDate: "2026-10-09", + lengthDays: 28, + periodEndDate: "2026-09-16", + updatedAt: touched(190), + }, + { + id: "cy-02", + userId, + startDate: "2026-10-10", + updatedAt: touched(180), + }, + { + id: "cy-03", + userId, + startDate: "2026-08-01", + deletedAt, + updatedAt: touched(170), + }, + ], + }); + await prisma.cycleDayLog.createMany({ + data: [ + { + id: "cd-01", + userId, + date: "2026-10-10", + cycleId: "cy-02", + flow: "MEDIUM", + updatedAt: touched(160), + }, + { + id: "cd-02", + userId, + date: "2026-10-11", + cycleId: "cy-02", + flow: "LIGHT", + basalBodyTempC: 36.4, + externalId: "hk-cycle-1", + updatedAt: touched(150), + }, + { + id: "cd-03", + userId, + date: "2026-10-09", + externalId: "hk-cycle-0", + deletedAt, + updatedAt: touched(140), + }, + ], + }); +} + +type Feed = { cursor: string | null; hasMore: boolean }; + +async function pull( + name: string, + query: Record, +): Promise { + wideEventRecorder.take(); + const res = await callRoute(GET, { + method: "GET", + route: "/api/sync/changes", + query, + auth: owner, + }); + const events = wideEventRecorder.take(); + expect(events.length).toBeGreaterThan(0); + await expectParity( + `sync-changes.${name}`, + stable( + { status: res.status, body: res.body, events: events.map(eventFacts) }, + { since: NOW }, + ), + ); + return res.data; +} + +beforeAll(async () => { + vi.useFakeTimers({ toFake: ["Date"], now: NOW }); + await truncateAllTables(prisma); + const user = await makeUser({ gender: "FEMALE", timezone: "UTC" }); + userId = user.id; + owner = await makeSession(user); + await prisma.cycleProfile.create({ data: { userId } }); + await seed(); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +describe("GET /api/sync/changes parity", () => { + let firstCursor: string; + + it("drains the feed in small pages to the end", async () => { + const first = await pull("01-first-page", { limit: 2 }); + expect(first.hasMore).toBe(true); + firstCursor = first.cursor!; + const second = await pull("02-second-page", { + limit: 2, + cursor: first.cursor!, + }); + const rest = await pull("03-the-rest", { + limit: 500, + cursor: second.cursor!, + }); + expect(rest.hasMore).toBe(false); + await pull("04-caught-up", { cursor: rest.cursor! }); + }); + + it("a cursor past retention, and one from before a reset", async () => { + await pull("05-past-retention", { + cursor: encodeCursor( + { + measurements: { updatedAtMs: NOW.getTime() - 200 * DAY, id: "m-01" }, + }, + NOW.getTime(), + ), + }); + await prisma.user.update({ + where: { id: userId }, + data: { syncResetAt: new Date(NOW.getTime() + MIN) }, + }); + await pull("06-before-reset", { cursor: firstCursor }); + await prisma.user.update({ + where: { id: userId }, + data: { syncResetAt: null }, + }); + }); + + it("the cycle module switched off", async () => { + await prisma.cycleProfile.update({ + where: { userId }, + data: { cycleTrackingEnabled: false }, + }); + await pull("07-cycle-off", { limit: 500 }); + await prisma.cycleProfile.update({ + where: { userId }, + data: { cycleTrackingEnabled: null }, + }); + }); + + it("a refused query", async () => { + await pull("08-invalid", { limit: 0 }); + }); +});