From 94ad53756edfe76b0d357ae79d48d4c80d9cd8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 14:58:14 +0200 Subject: [PATCH] test(import): pin the CSV import before moving its writer --- .../import-csv.01-dry-run.json.snap | 70 ++++++ .../import-csv.02-first-import.json.snap | 206 ++++++++++++++++ .../import-csv.03-same-file.json.snap | 196 +++++++++++++++ .../import-csv.04-changed-file.json.snap | 199 ++++++++++++++++ .../import-csv.05-unknown-parameter.json.snap | 163 +++++++++++++ .../import-csv.06-no-header.json.snap | 151 ++++++++++++ .../routes/parity/import-csv.parity.test.ts | 224 ++++++++++++++++++ 7 files changed, 1209 insertions(+) create mode 100644 tests/integration/routes/parity/__snapshots__/import-csv.01-dry-run.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/import-csv.02-first-import.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/import-csv.03-same-file.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/import-csv.04-changed-file.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/import-csv.05-unknown-parameter.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/import-csv.06-no-header.json.snap create mode 100644 tests/integration/routes/parity/import-csv.parity.test.ts diff --git a/tests/integration/routes/parity/__snapshots__/import-csv.01-dry-run.json.snap b/tests/integration/routes/parity/__snapshots__/import-csv.01-dry-run.json.snap new file mode 100644 index 000000000..40b84b9e5 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/import-csv.01-dry-run.json.snap @@ -0,0 +1,70 @@ +{ + "status": 200, + "body": { + "data": { + "inserted": 7, + "updated": 0, + "skipped": 1, + "total": 8, + "dryRun": true, + "rows": [ + { + "line": 2, + "status": "inserted" + }, + { + "line": 3, + "status": "inserted" + }, + { + "line": 4, + "status": "inserted" + }, + { + "line": 5, + "status": "inserted" + }, + { + "line": 6, + "status": "inserted" + }, + { + "line": 7, + "status": "inserted" + }, + { + "line": 8, + "status": "inserted" + }, + { + "line": 9, + "status": "skipped", + "reason": "invalid_value" + } + ] + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "import.csv.upload" + }, + "meta": { + "import_csv_inserted": 7, + "import_csv_updated": 0, + "import_csv_skipped": 1, + "import_csv_dry_run": true + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "measurements": [], + "rollups": [], + "audit": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/import-csv.02-first-import.json.snap b/tests/integration/routes/parity/__snapshots__/import-csv.02-first-import.json.snap new file mode 100644 index 000000000..670cc5f08 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/import-csv.02-first-import.json.snap @@ -0,0 +1,206 @@ +{ + "status": 200, + "body": { + "data": { + "inserted": 5, + "updated": 1, + "skipped": 2, + "total": 8, + "dryRun": false, + "rows": [ + { + "line": 2, + "status": "inserted" + }, + { + "line": 3, + "status": "inserted" + }, + { + "line": 4, + "status": "updated" + }, + { + "line": 5, + "status": "inserted" + }, + { + "line": 6, + "status": "skipped", + "reason": "duplicate" + }, + { + "line": 7, + "status": "inserted" + }, + { + "line": 8, + "status": "inserted" + }, + { + "line": 9, + "status": "skipped", + "reason": "invalid_value" + } + ] + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "import.csv.upload" + }, + "meta": { + "import_csv_inserted": 5, + "import_csv_updated": 1, + "import_csv_skipped": 2, + "import_csv_dry_run": false + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "emitDataArrival", + "args": [ + { + "userId": "", + "kind": "weight", + "newestSampleAt": "2026-10-11T06:30:00.000Z", + "insertedCount": 2, + "refId": "", + "source": "csv_import" + } + ] + }, + { + "effect": "maybeEnqueueMorningRefresh", + "args": [ + "", + [ + "2026-10-12T06:00:00.000Z" + ] + ] + }, + { + "effect": "invalidateStatusInsightsForTypes", + "args": [ + "", + [ + "WEIGHT", + "SLEEP_DURATION", + "BLOOD_GLUCOSE", + "PULSE" + ], + { + "source": "manual" + } + ] + } + ], + "stored": { + "measurements": [ + { + "type": "WEIGHT", + "value": 81.4, + "unit": "kg", + "source": "IMPORT", + "externalId": "exp-w-1", + "measuredAt": "2026-10-10T06:30:00.000Z", + "notes": null, + "notesEncrypted": "", + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "WEIGHT", + "value": 82, + "unit": "kg", + "source": "IMPORT", + "externalId": "exp-w-2", + "measuredAt": "2026-10-11T06:30:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "PULSE", + "value": 64, + "unit": "bpm", + "source": "IMPORT", + "externalId": null, + "measuredAt": "2026-10-12T07:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "SLEEP_DURATION", + "value": 412, + "unit": "minutes", + "source": "IMPORT", + "externalId": "exp-s-1", + "measuredAt": "2026-10-12T06:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "BLOOD_GLUCOSE", + "value": 104, + "unit": "mg/dL", + "source": "IMPORT", + "externalId": null, + "measuredAt": "2026-10-11T08:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": "FASTING", + "deletedAt": null + } + ], + "rollups": [ + { + "type": "WEIGHT", + "bucketStart": "2026-10-10T00:00:00.000Z", + "count": 1, + "mean": 81.4 + }, + { + "type": "WEIGHT", + "bucketStart": "2026-10-11T00:00:00.000Z", + "count": 1, + "mean": 82 + }, + { + "type": "PULSE", + "bucketStart": "2026-10-12T00:00:00.000Z", + "count": 1, + "mean": 64 + }, + { + "type": "SLEEP_DURATION", + "bucketStart": "2026-10-12T00:00:00.000Z", + "count": 1, + "mean": 412 + }, + { + "type": "BLOOD_GLUCOSE", + "bucketStart": "2026-10-11T00:00:00.000Z", + "count": 1, + "mean": 104 + } + ], + "audit": [ + { + "action": "import.csv.upload", + "details": "{\"inserted\":5,\"updated\":1,\"skipped\":2}" + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/import-csv.03-same-file.json.snap b/tests/integration/routes/parity/__snapshots__/import-csv.03-same-file.json.snap new file mode 100644 index 000000000..8751f8834 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/import-csv.03-same-file.json.snap @@ -0,0 +1,196 @@ +{ + "status": 200, + "body": { + "data": { + "inserted": 0, + "updated": 2, + "skipped": 6, + "total": 8, + "dryRun": false, + "rows": [ + { + "line": 2, + "status": "skipped", + "reason": "duplicate" + }, + { + "line": 3, + "status": "updated" + }, + { + "line": 4, + "status": "updated" + }, + { + "line": 5, + "status": "skipped", + "reason": "duplicate" + }, + { + "line": 6, + "status": "skipped", + "reason": "duplicate" + }, + { + "line": 7, + "status": "skipped", + "reason": "duplicate" + }, + { + "line": 8, + "status": "skipped", + "reason": "duplicate" + }, + { + "line": 9, + "status": "skipped", + "reason": "invalid_value" + } + ] + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "import.csv.upload" + }, + "meta": { + "import_csv_inserted": 0, + "import_csv_updated": 2, + "import_csv_skipped": 6, + "import_csv_dry_run": false + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "maybeEnqueueMorningRefresh", + "args": [ + "", + [] + ] + }, + { + "effect": "invalidateStatusInsightsForTypes", + "args": [ + "", + [ + "WEIGHT" + ], + { + "source": "manual" + } + ] + } + ], + "stored": { + "measurements": [ + { + "type": "WEIGHT", + "value": 81.4, + "unit": "kg", + "source": "IMPORT", + "externalId": "exp-w-1", + "measuredAt": "2026-10-10T06:30:00.000Z", + "notes": null, + "notesEncrypted": "", + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "WEIGHT", + "value": 82, + "unit": "kg", + "source": "IMPORT", + "externalId": "exp-w-2", + "measuredAt": "2026-10-11T06:30:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "PULSE", + "value": 64, + "unit": "bpm", + "source": "IMPORT", + "externalId": null, + "measuredAt": "2026-10-12T07:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "SLEEP_DURATION", + "value": 412, + "unit": "minutes", + "source": "IMPORT", + "externalId": "exp-s-1", + "measuredAt": "2026-10-12T06:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "BLOOD_GLUCOSE", + "value": 104, + "unit": "mg/dL", + "source": "IMPORT", + "externalId": null, + "measuredAt": "2026-10-11T08:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": "FASTING", + "deletedAt": null + } + ], + "rollups": [ + { + "type": "WEIGHT", + "bucketStart": "2026-10-10T00:00:00.000Z", + "count": 1, + "mean": 81.4 + }, + { + "type": "WEIGHT", + "bucketStart": "2026-10-11T00:00:00.000Z", + "count": 1, + "mean": 82 + }, + { + "type": "PULSE", + "bucketStart": "2026-10-12T00:00:00.000Z", + "count": 1, + "mean": 64 + }, + { + "type": "SLEEP_DURATION", + "bucketStart": "2026-10-12T00:00:00.000Z", + "count": 1, + "mean": 412 + }, + { + "type": "BLOOD_GLUCOSE", + "bucketStart": "2026-10-11T00:00:00.000Z", + "count": 1, + "mean": 104 + } + ], + "audit": [ + { + "action": "import.csv.upload", + "details": "{\"inserted\":5,\"updated\":1,\"skipped\":2}" + }, + { + "action": "import.csv.upload", + "details": "{\"inserted\":0,\"updated\":2,\"skipped\":6}" + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/import-csv.04-changed-file.json.snap b/tests/integration/routes/parity/__snapshots__/import-csv.04-changed-file.json.snap new file mode 100644 index 000000000..374bc45ae --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/import-csv.04-changed-file.json.snap @@ -0,0 +1,199 @@ +{ + "status": 200, + "body": { + "data": { + "inserted": 1, + "updated": 2, + "skipped": 1, + "total": 4, + "dryRun": false, + "rows": [ + { + "line": 2, + "status": "skipped", + "reason": "duplicate" + }, + { + "line": 3, + "status": "updated" + }, + { + "line": 4, + "status": "updated" + }, + { + "line": 5, + "status": "inserted" + } + ] + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "import.csv.upload" + }, + "meta": { + "import_csv_inserted": 1, + "import_csv_updated": 2, + "import_csv_skipped": 1, + "import_csv_dry_run": false + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "maybeEnqueueMorningRefresh", + "args": [ + "", + [] + ] + }, + { + "effect": "invalidateStatusInsightsForTypes", + "args": [ + "", + [ + "WEIGHT", + "SLEEP_DURATION", + "PULSE" + ], + { + "source": "manual" + } + ] + } + ], + "stored": { + "measurements": [ + { + "type": "WEIGHT", + "value": 82, + "unit": "kg", + "source": "IMPORT", + "externalId": "exp-w-2", + "measuredAt": "2026-10-09T06:30:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "WEIGHT", + "value": 81.4, + "unit": "kg", + "source": "IMPORT", + "externalId": "exp-w-1", + "measuredAt": "2026-10-10T06:30:00.000Z", + "notes": null, + "notesEncrypted": "", + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "PULSE", + "value": 64, + "unit": "bpm", + "source": "IMPORT", + "externalId": null, + "measuredAt": "2026-10-12T07:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "PULSE", + "value": 66, + "unit": "bpm", + "source": "IMPORT", + "externalId": null, + "measuredAt": "2026-10-13T07:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "SLEEP_DURATION", + "value": 430, + "unit": "minutes", + "source": "IMPORT", + "externalId": "exp-s-1", + "measuredAt": "2026-10-12T06:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "BLOOD_GLUCOSE", + "value": 104, + "unit": "mg/dL", + "source": "IMPORT", + "externalId": null, + "measuredAt": "2026-10-11T08:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": "FASTING", + "deletedAt": null + } + ], + "rollups": [ + { + "type": "WEIGHT", + "bucketStart": "2026-10-09T00:00:00.000Z", + "count": 1, + "mean": 82 + }, + { + "type": "WEIGHT", + "bucketStart": "2026-10-10T00:00:00.000Z", + "count": 1, + "mean": 81.4 + }, + { + "type": "PULSE", + "bucketStart": "2026-10-12T00:00:00.000Z", + "count": 1, + "mean": 64 + }, + { + "type": "PULSE", + "bucketStart": "2026-10-13T00:00:00.000Z", + "count": 1, + "mean": 66 + }, + { + "type": "SLEEP_DURATION", + "bucketStart": "2026-10-12T00:00:00.000Z", + "count": 1, + "mean": 430 + }, + { + "type": "BLOOD_GLUCOSE", + "bucketStart": "2026-10-11T00:00:00.000Z", + "count": 1, + "mean": 104 + } + ], + "audit": [ + { + "action": "import.csv.upload", + "details": "{\"inserted\":5,\"updated\":1,\"skipped\":2}" + }, + { + "action": "import.csv.upload", + "details": "{\"inserted\":0,\"updated\":2,\"skipped\":6}" + }, + { + "action": "import.csv.upload", + "details": "{\"inserted\":1,\"updated\":2,\"skipped\":1}" + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/import-csv.05-unknown-parameter.json.snap b/tests/integration/routes/parity/__snapshots__/import-csv.05-unknown-parameter.json.snap new file mode 100644 index 000000000..40994371b --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/import-csv.05-unknown-parameter.json.snap @@ -0,0 +1,163 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "Validation failed", + "details": { + "issues": [ + { + "path": "", + "code": "unrecognized_keys", + "message": "Unrecognized key: \"dryrun\"", + "keys": [ + "dryrun" + ] + } + ] + }, + "meta": { + "errorCode": "http.invalid", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": { + "name": "import.csv.upload" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "measurements": [ + { + "type": "WEIGHT", + "value": 82, + "unit": "kg", + "source": "IMPORT", + "externalId": "exp-w-2", + "measuredAt": "2026-10-09T06:30:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "WEIGHT", + "value": 81.4, + "unit": "kg", + "source": "IMPORT", + "externalId": "exp-w-1", + "measuredAt": "2026-10-10T06:30:00.000Z", + "notes": null, + "notesEncrypted": "", + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "PULSE", + "value": 64, + "unit": "bpm", + "source": "IMPORT", + "externalId": null, + "measuredAt": "2026-10-12T07:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "PULSE", + "value": 66, + "unit": "bpm", + "source": "IMPORT", + "externalId": null, + "measuredAt": "2026-10-13T07:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "SLEEP_DURATION", + "value": 430, + "unit": "minutes", + "source": "IMPORT", + "externalId": "exp-s-1", + "measuredAt": "2026-10-12T06:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "BLOOD_GLUCOSE", + "value": 104, + "unit": "mg/dL", + "source": "IMPORT", + "externalId": null, + "measuredAt": "2026-10-11T08:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": "FASTING", + "deletedAt": null + } + ], + "rollups": [ + { + "type": "WEIGHT", + "bucketStart": "2026-10-09T00:00:00.000Z", + "count": 1, + "mean": 82 + }, + { + "type": "WEIGHT", + "bucketStart": "2026-10-10T00:00:00.000Z", + "count": 1, + "mean": 81.4 + }, + { + "type": "PULSE", + "bucketStart": "2026-10-12T00:00:00.000Z", + "count": 1, + "mean": 64 + }, + { + "type": "PULSE", + "bucketStart": "2026-10-13T00:00:00.000Z", + "count": 1, + "mean": 66 + }, + { + "type": "SLEEP_DURATION", + "bucketStart": "2026-10-12T00:00:00.000Z", + "count": 1, + "mean": 430 + }, + { + "type": "BLOOD_GLUCOSE", + "bucketStart": "2026-10-11T00:00:00.000Z", + "count": 1, + "mean": 104 + } + ], + "audit": [ + { + "action": "import.csv.upload", + "details": "{\"inserted\":5,\"updated\":1,\"skipped\":2}" + }, + { + "action": "import.csv.upload", + "details": "{\"inserted\":0,\"updated\":2,\"skipped\":6}" + }, + { + "action": "import.csv.upload", + "details": "{\"inserted\":1,\"updated\":2,\"skipped\":1}" + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/import-csv.06-no-header.json.snap b/tests/integration/routes/parity/__snapshots__/import-csv.06-no-header.json.snap new file mode 100644 index 000000000..7bb605762 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/import-csv.06-no-header.json.snap @@ -0,0 +1,151 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "Missing required column(s): type, value, unit, measuredat. Required header: type,value,unit,measuredat[,glucosecontext,notes,externalid].", + "meta": { + "errorCode": "http.invalid", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": { + "name": "import.csv.upload" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "measurements": [ + { + "type": "WEIGHT", + "value": 82, + "unit": "kg", + "source": "IMPORT", + "externalId": "exp-w-2", + "measuredAt": "2026-10-09T06:30:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "WEIGHT", + "value": 81.4, + "unit": "kg", + "source": "IMPORT", + "externalId": "exp-w-1", + "measuredAt": "2026-10-10T06:30:00.000Z", + "notes": null, + "notesEncrypted": "", + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "PULSE", + "value": 64, + "unit": "bpm", + "source": "IMPORT", + "externalId": null, + "measuredAt": "2026-10-12T07:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "PULSE", + "value": 66, + "unit": "bpm", + "source": "IMPORT", + "externalId": null, + "measuredAt": "2026-10-13T07:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "SLEEP_DURATION", + "value": 430, + "unit": "minutes", + "source": "IMPORT", + "externalId": "exp-s-1", + "measuredAt": "2026-10-12T06:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": null, + "deletedAt": null + }, + { + "type": "BLOOD_GLUCOSE", + "value": 104, + "unit": "mg/dL", + "source": "IMPORT", + "externalId": null, + "measuredAt": "2026-10-11T08:00:00.000Z", + "notes": null, + "notesEncrypted": null, + "glucoseContext": "FASTING", + "deletedAt": null + } + ], + "rollups": [ + { + "type": "WEIGHT", + "bucketStart": "2026-10-09T00:00:00.000Z", + "count": 1, + "mean": 82 + }, + { + "type": "WEIGHT", + "bucketStart": "2026-10-10T00:00:00.000Z", + "count": 1, + "mean": 81.4 + }, + { + "type": "PULSE", + "bucketStart": "2026-10-12T00:00:00.000Z", + "count": 1, + "mean": 64 + }, + { + "type": "PULSE", + "bucketStart": "2026-10-13T00:00:00.000Z", + "count": 1, + "mean": 66 + }, + { + "type": "SLEEP_DURATION", + "bucketStart": "2026-10-12T00:00:00.000Z", + "count": 1, + "mean": 430 + }, + { + "type": "BLOOD_GLUCOSE", + "bucketStart": "2026-10-11T00:00:00.000Z", + "count": 1, + "mean": 104 + } + ], + "audit": [ + { + "action": "import.csv.upload", + "details": "{\"inserted\":5,\"updated\":1,\"skipped\":2}" + }, + { + "action": "import.csv.upload", + "details": "{\"inserted\":0,\"updated\":2,\"skipped\":6}" + }, + { + "action": "import.csv.upload", + "details": "{\"inserted\":1,\"updated\":2,\"skipped\":1}" + } + ] + } +} diff --git a/tests/integration/routes/parity/import-csv.parity.test.ts b/tests/integration/routes/parity/import-csv.parity.test.ts new file mode 100644 index 000000000..60ee5ba50 --- /dev/null +++ b/tests/integration/routes/parity/import-csv.parity.test.ts @@ -0,0 +1,224 @@ +/** + * Parity for `POST /api/import/csv` while its batched writer moves into + * `src/lib/import/`. + * + * A dry run of a mixed file (rows with and without an external id, a key + * repeated inside the file, a natural-key twin, a row the parser refuses), + * the same file for real, the same file again, a changed file that moves a + * reading to another day and re-imports a deleted row, and the refusals (an + * unknown parameter, a file without a usable header). After each request + * the snapshot holds the response, the wide event, the recorded arrival and + * refresh tails, and the stored measurements, rollups and audit rows. The + * shared import bucket is emptied before each request so the walk never + * meets its own rate limit. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { POST } from "@/app/api/import/csv/route"; + +import { callRoute, type RouteAuth } from "../../../kit/call-route"; +import { makeSession, makeUser } from "../../../kit/factories"; +import { + effectRecorder, + 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); + }, + }; +}); +vi.mock("@/lib/arrivals/emit-shared", async (importOriginal) => { + const actual = + await importOriginal(); + const { effectRecorder } = await import("../../../kit/parity"); + return { + ...actual, + emitDataArrival: async (...args: unknown[]) => { + effectRecorder.record("emitDataArrival", args); + }, + }; +}); +vi.mock("@/lib/daily/morning-refresh-trigger", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/daily/morning-refresh-trigger") + >(); + const { effectRecorder } = await import("../../../kit/parity"); + return { + ...actual, + maybeEnqueueMorningRefresh: async (...args: unknown[]) => { + effectRecorder.record("maybeEnqueueMorningRefresh", args); + }, + }; +}); +vi.mock("@/lib/jobs/pr-detection", async (importOriginal) => { + const actual = + await importOriginal(); + const { effectRecorder } = await import("../../../kit/parity"); + return { + ...actual, + enqueuePrDetection: async (...args: unknown[]) => { + effectRecorder.record("enqueuePrDetection", args); + }, + }; +}); + +vi.mock("@/lib/insights/status-invalidation", async (importOriginal) => { + const actual = + await importOriginal(); + const { effectRecorder } = await import("../../../kit/parity"); + return { + ...actual, + // Detached from the request but inside its context: run for real, its + // annotate can land on the request's wide event or miss it, depending on + // timing. Recorded instead, like the other tails. + invalidateStatusInsightsForTypes: async (...args: unknown[]) => { + effectRecorder.record("invalidateStatusInsightsForTypes", args); + }, + }; +}); + +const prisma = getPrismaClient(); +const NOW = new Date("2026-10-15T12:00:00.000Z"); +const HEADER = "type,value,unit,measuredAt,glucoseContext,notes,externalId"; + +let userId: string; +let owner: RouteAuth; + +async function stored() { + const [measurements, rollups, audit] = await Promise.all([ + prisma.measurement.findMany({ + where: { userId }, + orderBy: [{ type: "asc" }, { measuredAt: "asc" }, { value: "asc" }], + select: { + type: true, + value: true, + unit: true, + source: true, + externalId: true, + measuredAt: true, + notes: true, + notesEncrypted: true, + glucoseContext: true, + deletedAt: true, + }, + }), + prisma.measurementRollup.findMany({ + where: { userId, granularity: "DAY" }, + orderBy: [{ type: "asc" }, { bucketStart: "asc" }], + select: { type: true, bucketStart: true, count: true, mean: true }, + }), + prisma.auditLog.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: { action: true, details: true }, + }), + ]); + return { measurements, rollups, audit }; +} + +async function step( + name: string, + lines: string[], + query: Record = {}, +): Promise { + await prisma.rateLimit.deleteMany({ where: { key: `import:${userId}` } }); + wideEventRecorder.take(); + effectRecorder.take(); + const res = await callRoute(POST, { + method: "POST", + route: "/api/import/csv", + query, + text: lines.join("\n"), + headers: { "content-type": "text/csv" }, + auth: owner, + }); + const events = wideEventRecorder.take(); + expect(events.length).toBeGreaterThan(0); + await expectParity( + `import-csv.${name}`, + stable( + { + status: res.status, + body: res.body, + events: events.map(eventFacts), + effects: effectRecorder.take(), + stored: await stored(), + }, + { since: NOW }, + ), + ); +} + +const FILE = [ + HEADER, + "WEIGHT,81.4,kg,2026-10-10T06:30:00.000Z,,after the run,exp-w-1", + "WEIGHT,81.9,kg,2026-10-11T06:30:00.000Z,,,exp-w-2", + // The same key again later in the file: the last value wins. + "WEIGHT,82.0,kg,2026-10-11T06:30:00.000Z,,,exp-w-2", + "BLOOD_GLUCOSE,104,mg/dL,2026-10-11T08:00:00.000Z,FASTING,,", + // A natural-key twin of the line above. + "BLOOD_GLUCOSE,104,mg/dL,2026-10-11T08:00:00.000Z,FASTING,,", + "SLEEP_DURATION,412,minutes,2026-10-12T06:00:00.000Z,,,exp-s-1", + "PULSE,64,bpm,2026-10-12T07:00:00.000Z,,,", + // Refused by the parser. + "WEIGHT,not-a-number,kg,2026-10-12T06:30:00.000Z,,,", +]; + +const CHANGED = [ + HEADER, + // Unchanged. + "WEIGHT,81.4,kg,2026-10-10T06:30:00.000Z,,after the run,exp-w-1", + // Moved to another day. + "WEIGHT,82.0,kg,2026-10-09T06:30:00.000Z,,,exp-w-2", + // Deleted since the last import; re-importing brings it back. + "SLEEP_DURATION,430,minutes,2026-10-12T06:00:00.000Z,,,exp-s-1", + "PULSE,66,bpm,2026-10-13T07:00:00.000Z,,,", +]; + +beforeAll(async () => { + vi.useFakeTimers({ toFake: ["Date"], now: NOW }); + await truncateAllTables(prisma); + const user = await makeUser({ timezone: "Europe/Berlin" }); + userId = user.id; + owner = await makeSession(user); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +describe("POST /api/import/csv parity", () => { + it("01 a dry run", async () => { + await step("01-dry-run", FILE, { dryRun: "1" }); + }); + + it("02 the file for real, then again", async () => { + await step("02-first-import", FILE); + await step("03-same-file", FILE); + }); + + it("04 a changed file over a deleted row", async () => { + await prisma.measurement.updateMany({ + where: { userId, externalId: "exp-s-1" }, + data: { deletedAt: new Date("2026-10-13T08:00:00.000Z") }, + }); + await step("04-changed-file", CHANGED); + }); + + it("05 the refusals", async () => { + await step("05-unknown-parameter", FILE, { dryrun: "1" }); + await step("06-no-header", ["just,some,words"]); + }); +});