From 74e08e6906f4c8eb70672e9c7db3bbf3fa1b9b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 14:30:19 +0200 Subject: [PATCH] test(mood): pin the bulk mood ingest before moving it into a service --- ...mood-entries-bulk.01-first-drain.json.snap | 224 ++++++++++++++++ .../mood-entries-bulk.02-same-drain.json.snap | 224 ++++++++++++++++ .../mood-entries-bulk.03-tombstone.json.snap | 167 ++++++++++++ .../mood-entries-bulk.04-too-large.json.snap | 149 +++++++++++ .../mood-entries-bulk.05-invalid.json.snap | 166 ++++++++++++ .../parity/mood-entries-bulk.parity.test.ts | 249 ++++++++++++++++++ 6 files changed, 1179 insertions(+) create mode 100644 tests/integration/routes/parity/__snapshots__/mood-entries-bulk.01-first-drain.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/mood-entries-bulk.02-same-drain.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/mood-entries-bulk.03-tombstone.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/mood-entries-bulk.04-too-large.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/mood-entries-bulk.05-invalid.json.snap create mode 100644 tests/integration/routes/parity/mood-entries-bulk.parity.test.ts diff --git a/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.01-first-drain.json.snap b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.01-first-drain.json.snap new file mode 100644 index 000000000..a87310c20 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.01-first-drain.json.snap @@ -0,0 +1,224 @@ +{ + "status": 200, + "body": { + "data": { + "processed": 7, + "inserted": 4, + "duplicates": 0, + "skipped": [ + { + "index": 3, + "reason": "Rating 3 for factor \"factor_parity_short\" is outside its scale 1..2" + }, + { + "index": 5, + "reason": "invalid_dimensions" + }, + { + "index": 6, + "reason": "unstable_external_id" + } + ], + "entries": [ + { + "index": 0, + "status": "inserted", + "id": "", + "externalId": "sd-1", + "droppedTagKeys": [ + "no_such_tag" + ], + "droppedFactorKeys": [ + "" + ] + }, + { + "index": 1, + "status": "inserted", + "id": "" + }, + { + "index": 2, + "status": "inserted", + "id": "", + "externalId": "sd-2" + }, + { + "index": 3, + "status": "skipped", + "reason": "Rating 3 for factor \"factor_parity_short\" is outside its scale 1..2", + "externalId": "sd-3" + }, + { + "index": 4, + "status": "inserted", + "id": "", + "externalId": "sd-4" + }, + { + "index": 5, + "status": "skipped", + "reason": "invalid_dimensions", + "externalId": "sd-5" + }, + { + "index": 6, + "status": "skipped", + "reason": "unstable_external_id", + "externalId": "" + } + ] + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "mood.bulk.ingest" + }, + "meta": { + "write_bucket": "own", + "processed": 7, + "inserted": 4, + "duplicates": 0, + "skipped": 3, + "dropped_tag_keys": 1, + "dropped_factor_keys": 1, + "external_id_rejected": 1, + "external_id_shapes": "object_description", + "external_id_surface": "mood.bulk", + "sync_checkpoint_at": "" + }, + "warnings": null, + "error": null + } + ], + "stored": { + "entries": [ + { + "id": "", + "date": "2026-10-12", + "tz": "Europe/Berlin", + "mood": "GUT", + "score": 4, + "moodA1": 7, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": "sd-4", + "moodLoggedAt": "2026-10-12T10:00:00.000Z", + "deletedAt": null, + "tagLinks": [], + "context": null + }, + { + "id": "", + "date": "2026-10-13", + "tz": "Europe/Berlin", + "mood": "SCHLECHT", + "score": 2, + "moodA1": 3, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": "sd-2", + "moodLoggedAt": "2026-10-13T21:00:00.000Z", + "deletedAt": null, + "tagLinks": [], + "context": { + "moodEntryId": "", + "userId": "", + "workStatus": "regular", + "workMinutes": 480, + "overtimeMinutes": null, + "workLoad": null, + "workSatisfaction": null, + "contactCircles": null, + "contactForm": null, + "contactExtent": null, + "contactQuality": null, + "contactSupport": null, + "leisureCategories": null, + "leisureMinutes": null, + "leisureJoy": null, + "leisureRecovery": null, + "eventType": null, + "eventValence": null, + "eventAt": null, + "notesEncrypted": null, + "createdAt": "", + "updatedAt": "" + } + }, + { + "id": "", + "date": "2026-10-14", + "tz": "Europe/Berlin", + "mood": "GUT", + "score": 4, + "moodA1": 7, + "stressA2": 7, + "energyA3": 4, + "connectionA4": null, + "stabilityA5": null, + "tags": "[\"slept well\"]", + "note": null, + "noteEncrypted": "", + "source": "WEB", + "externalId": "sd-1", + "moodLoggedAt": "2026-10-14T07:00:00.000Z", + "deletedAt": null, + "tagLinks": [ + { + "moodTag": { + "key": "alcohol" + }, + "rating": null + }, + { + "moodTag": { + "key": "factor_parity_short" + }, + "rating": 1 + } + ], + "context": null + }, + { + "id": "", + "date": "2026-10-14", + "tz": "Europe/Berlin", + "mood": "OKAY", + "score": 3, + "moodA1": 5, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": null, + "moodLoggedAt": "2026-10-14T19:30:00.000Z", + "deletedAt": null, + "tagLinks": [], + "context": null + } + ], + "user": { + "lastSyncedAt": "" + } + } +} diff --git a/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.02-same-drain.json.snap b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.02-same-drain.json.snap new file mode 100644 index 000000000..788dff9c3 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.02-same-drain.json.snap @@ -0,0 +1,224 @@ +{ + "status": 200, + "body": { + "data": { + "processed": 7, + "inserted": 0, + "duplicates": 4, + "skipped": [ + { + "index": 3, + "reason": "Rating 3 for factor \"factor_parity_short\" is outside its scale 1..2" + }, + { + "index": 5, + "reason": "invalid_dimensions" + }, + { + "index": 6, + "reason": "unstable_external_id" + } + ], + "entries": [ + { + "index": 0, + "status": "duplicate", + "id": "", + "externalId": "sd-1", + "droppedTagKeys": [ + "no_such_tag" + ], + "droppedFactorKeys": [ + "" + ] + }, + { + "index": 1, + "status": "duplicate", + "id": "" + }, + { + "index": 2, + "status": "duplicate", + "id": "", + "externalId": "sd-2" + }, + { + "index": 3, + "status": "skipped", + "reason": "Rating 3 for factor \"factor_parity_short\" is outside its scale 1..2", + "externalId": "sd-3" + }, + { + "index": 4, + "status": "duplicate", + "id": "", + "externalId": "sd-4" + }, + { + "index": 5, + "status": "skipped", + "reason": "invalid_dimensions", + "externalId": "sd-5" + }, + { + "index": 6, + "status": "skipped", + "reason": "unstable_external_id", + "externalId": "" + } + ] + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "mood.bulk.ingest" + }, + "meta": { + "write_bucket": "own", + "processed": 7, + "inserted": 0, + "duplicates": 4, + "skipped": 3, + "dropped_tag_keys": 1, + "dropped_factor_keys": 1, + "external_id_rejected": 1, + "external_id_shapes": "object_description", + "external_id_surface": "mood.bulk", + "sync_checkpoint_at": "" + }, + "warnings": null, + "error": null + } + ], + "stored": { + "entries": [ + { + "id": "", + "date": "2026-10-12", + "tz": "Europe/Berlin", + "mood": "GUT", + "score": 4, + "moodA1": 7, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": "sd-4", + "moodLoggedAt": "2026-10-12T10:00:00.000Z", + "deletedAt": null, + "tagLinks": [], + "context": null + }, + { + "id": "", + "date": "2026-10-13", + "tz": "Europe/Berlin", + "mood": "SCHLECHT", + "score": 2, + "moodA1": 3, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": "sd-2", + "moodLoggedAt": "2026-10-13T21:00:00.000Z", + "deletedAt": null, + "tagLinks": [], + "context": { + "moodEntryId": "", + "userId": "", + "workStatus": "regular", + "workMinutes": 480, + "overtimeMinutes": null, + "workLoad": null, + "workSatisfaction": null, + "contactCircles": null, + "contactForm": null, + "contactExtent": null, + "contactQuality": null, + "contactSupport": null, + "leisureCategories": null, + "leisureMinutes": null, + "leisureJoy": null, + "leisureRecovery": null, + "eventType": null, + "eventValence": null, + "eventAt": null, + "notesEncrypted": null, + "createdAt": "", + "updatedAt": "" + } + }, + { + "id": "", + "date": "2026-10-14", + "tz": "Europe/Berlin", + "mood": "GUT", + "score": 4, + "moodA1": 7, + "stressA2": 7, + "energyA3": 4, + "connectionA4": null, + "stabilityA5": null, + "tags": "[\"slept well\"]", + "note": null, + "noteEncrypted": "", + "source": "WEB", + "externalId": "sd-1", + "moodLoggedAt": "2026-10-14T07:00:00.000Z", + "deletedAt": null, + "tagLinks": [ + { + "moodTag": { + "key": "alcohol" + }, + "rating": null + }, + { + "moodTag": { + "key": "factor_parity_short" + }, + "rating": 1 + } + ], + "context": null + }, + { + "id": "", + "date": "2026-10-14", + "tz": "Europe/Berlin", + "mood": "OKAY", + "score": 3, + "moodA1": 5, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": null, + "moodLoggedAt": "2026-10-14T19:30:00.000Z", + "deletedAt": null, + "tagLinks": [], + "context": null + } + ], + "user": { + "lastSyncedAt": "" + } + } +} diff --git a/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.03-tombstone.json.snap b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.03-tombstone.json.snap new file mode 100644 index 000000000..dbab40614 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.03-tombstone.json.snap @@ -0,0 +1,167 @@ +{ + "status": 200, + "body": { + "data": { + "processed": 1, + "inserted": 0, + "duplicates": 1, + "skipped": [], + "entries": [ + { + "index": 0, + "status": "duplicate", + "id": "", + "externalId": "sd-2" + } + ] + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "mood.bulk.ingest" + }, + "meta": { + "write_bucket": "own", + "processed": 1, + "inserted": 0, + "duplicates": 1, + "skipped": 0, + "dropped_tag_keys": 0, + "dropped_factor_keys": 0, + "sync_checkpoint_at": "" + }, + "warnings": null, + "error": null + } + ], + "stored": { + "entries": [ + { + "id": "", + "date": "2026-10-12", + "tz": "Europe/Berlin", + "mood": "GUT", + "score": 4, + "moodA1": 7, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": "sd-4", + "moodLoggedAt": "2026-10-12T10:00:00.000Z", + "deletedAt": null, + "tagLinks": [], + "context": null + }, + { + "id": "", + "date": "2026-10-13", + "tz": "Europe/Berlin", + "mood": "SCHLECHT", + "score": 2, + "moodA1": 3, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": "sd-2", + "moodLoggedAt": "2026-10-13T21:00:00.000Z", + "deletedAt": "2026-10-14T20:00:00.000Z", + "tagLinks": [], + "context": { + "moodEntryId": "", + "userId": "", + "workStatus": "regular", + "workMinutes": 480, + "overtimeMinutes": null, + "workLoad": null, + "workSatisfaction": null, + "contactCircles": null, + "contactForm": null, + "contactExtent": null, + "contactQuality": null, + "contactSupport": null, + "leisureCategories": null, + "leisureMinutes": null, + "leisureJoy": null, + "leisureRecovery": null, + "eventType": null, + "eventValence": null, + "eventAt": null, + "notesEncrypted": null, + "createdAt": "", + "updatedAt": "" + } + }, + { + "id": "", + "date": "2026-10-14", + "tz": "Europe/Berlin", + "mood": "GUT", + "score": 4, + "moodA1": 7, + "stressA2": 7, + "energyA3": 4, + "connectionA4": null, + "stabilityA5": null, + "tags": "[\"slept well\"]", + "note": null, + "noteEncrypted": "", + "source": "WEB", + "externalId": "sd-1", + "moodLoggedAt": "2026-10-14T07:00:00.000Z", + "deletedAt": null, + "tagLinks": [ + { + "moodTag": { + "key": "alcohol" + }, + "rating": null + }, + { + "moodTag": { + "key": "factor_parity_short" + }, + "rating": 1 + } + ], + "context": null + }, + { + "id": "", + "date": "2026-10-14", + "tz": "Europe/Berlin", + "mood": "OKAY", + "score": 3, + "moodA1": 5, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": null, + "moodLoggedAt": "2026-10-14T19:30:00.000Z", + "deletedAt": null, + "tagLinks": [], + "context": null + } + ], + "user": { + "lastSyncedAt": "" + } + } +} diff --git a/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.04-too-large.json.snap b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.04-too-large.json.snap new file mode 100644 index 000000000..d7de3214c --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.04-too-large.json.snap @@ -0,0 +1,149 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "Batch exceeds the 500-entry limit", + "meta": { + "errorCode": "mood.bulk.too_large", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": null, + "meta": { + "write_bucket": "own" + }, + "warnings": null, + "error": null + } + ], + "stored": { + "entries": [ + { + "id": "", + "date": "2026-10-12", + "tz": "Europe/Berlin", + "mood": "GUT", + "score": 4, + "moodA1": 7, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": "sd-4", + "moodLoggedAt": "2026-10-12T10:00:00.000Z", + "deletedAt": null, + "tagLinks": [], + "context": null + }, + { + "id": "", + "date": "2026-10-13", + "tz": "Europe/Berlin", + "mood": "SCHLECHT", + "score": 2, + "moodA1": 3, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": "sd-2", + "moodLoggedAt": "2026-10-13T21:00:00.000Z", + "deletedAt": "2026-10-14T20:00:00.000Z", + "tagLinks": [], + "context": { + "moodEntryId": "", + "userId": "", + "workStatus": "regular", + "workMinutes": 480, + "overtimeMinutes": null, + "workLoad": null, + "workSatisfaction": null, + "contactCircles": null, + "contactForm": null, + "contactExtent": null, + "contactQuality": null, + "contactSupport": null, + "leisureCategories": null, + "leisureMinutes": null, + "leisureJoy": null, + "leisureRecovery": null, + "eventType": null, + "eventValence": null, + "eventAt": null, + "notesEncrypted": null, + "createdAt": "", + "updatedAt": "" + } + }, + { + "id": "", + "date": "2026-10-14", + "tz": "Europe/Berlin", + "mood": "GUT", + "score": 4, + "moodA1": 7, + "stressA2": 7, + "energyA3": 4, + "connectionA4": null, + "stabilityA5": null, + "tags": "[\"slept well\"]", + "note": null, + "noteEncrypted": "", + "source": "WEB", + "externalId": "sd-1", + "moodLoggedAt": "2026-10-14T07:00:00.000Z", + "deletedAt": null, + "tagLinks": [ + { + "moodTag": { + "key": "alcohol" + }, + "rating": null + }, + { + "moodTag": { + "key": "factor_parity_short" + }, + "rating": 1 + } + ], + "context": null + }, + { + "id": "", + "date": "2026-10-14", + "tz": "Europe/Berlin", + "mood": "OKAY", + "score": 3, + "moodA1": 5, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": null, + "moodLoggedAt": "2026-10-14T19:30:00.000Z", + "deletedAt": null, + "tagLinks": [], + "context": null + } + ], + "user": { + "lastSyncedAt": "" + } + } +} diff --git a/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.05-invalid.json.snap b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.05-invalid.json.snap new file mode 100644 index 000000000..a6898965c --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.05-invalid.json.snap @@ -0,0 +1,166 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "Validation failed", + "details": { + "issues": [ + { + "path": "entries.0.mood", + "code": "invalid_value", + "message": "Invalid option: expected one of \"SUPER_GUT\"|\"GUT\"|\"OKAY\"|\"SCHLECHT\"|\"LAUSIG\"" + }, + { + "path": "entries.0.moodLoggedAt", + "code": "invalid_type", + "message": "Invalid input: expected string, received undefined" + } + ] + }, + "meta": { + "errorCode": "mood.bulk.invalid", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": { + "name": "mood.bulk.validation-failed" + }, + "meta": { + "write_bucket": "own", + "issue_count": 2 + }, + "warnings": null, + "error": null + } + ], + "stored": { + "entries": [ + { + "id": "", + "date": "2026-10-12", + "tz": "Europe/Berlin", + "mood": "GUT", + "score": 4, + "moodA1": 7, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": "sd-4", + "moodLoggedAt": "2026-10-12T10:00:00.000Z", + "deletedAt": null, + "tagLinks": [], + "context": null + }, + { + "id": "", + "date": "2026-10-13", + "tz": "Europe/Berlin", + "mood": "SCHLECHT", + "score": 2, + "moodA1": 3, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": "sd-2", + "moodLoggedAt": "2026-10-13T21:00:00.000Z", + "deletedAt": "2026-10-14T20:00:00.000Z", + "tagLinks": [], + "context": { + "moodEntryId": "", + "userId": "", + "workStatus": "regular", + "workMinutes": 480, + "overtimeMinutes": null, + "workLoad": null, + "workSatisfaction": null, + "contactCircles": null, + "contactForm": null, + "contactExtent": null, + "contactQuality": null, + "contactSupport": null, + "leisureCategories": null, + "leisureMinutes": null, + "leisureJoy": null, + "leisureRecovery": null, + "eventType": null, + "eventValence": null, + "eventAt": null, + "notesEncrypted": null, + "createdAt": "", + "updatedAt": "" + } + }, + { + "id": "", + "date": "2026-10-14", + "tz": "Europe/Berlin", + "mood": "GUT", + "score": 4, + "moodA1": 7, + "stressA2": 7, + "energyA3": 4, + "connectionA4": null, + "stabilityA5": null, + "tags": "[\"slept well\"]", + "note": null, + "noteEncrypted": "", + "source": "WEB", + "externalId": "sd-1", + "moodLoggedAt": "2026-10-14T07:00:00.000Z", + "deletedAt": null, + "tagLinks": [ + { + "moodTag": { + "key": "alcohol" + }, + "rating": null + }, + { + "moodTag": { + "key": "factor_parity_short" + }, + "rating": 1 + } + ], + "context": null + }, + { + "id": "", + "date": "2026-10-14", + "tz": "Europe/Berlin", + "mood": "OKAY", + "score": 3, + "moodA1": 5, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MANUAL", + "externalId": null, + "moodLoggedAt": "2026-10-14T19:30:00.000Z", + "deletedAt": null, + "tagLinks": [], + "context": null + } + ], + "user": { + "lastSyncedAt": "" + } + } +} diff --git a/tests/integration/routes/parity/mood-entries-bulk.parity.test.ts b/tests/integration/routes/parity/mood-entries-bulk.parity.test.ts new file mode 100644 index 000000000..6c4a79519 --- /dev/null +++ b/tests/integration/routes/parity/mood-entries-bulk.parity.test.ts @@ -0,0 +1,249 @@ +/** + * Parity for `POST /api/mood-entries/bulk` while its ingest moves into + * `src/lib/mood/`. + * + * A first drain of mixed entries (with and without an external id, free + * tags, catalogue tags with an unknown key, a rated factor in and out of its + * scale, a day context, dimensions in and out of range, an unstable id), + * the same drain again, a re-post of an entry the person deleted, and the + * two whole-batch refusals. After each request the snapshot holds the + * response, the wide event and the stored entries with their links and + * context, a sealed note as its byte length. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { POST } from "@/app/api/mood-entries/bulk/route"; + +import { callRoute, type RouteAuth } from "../../../kit/call-route"; +import { makeSession, makeUser } from "../../../kit/factories"; +import { + eventFacts, + expectParity, + stable, + wideEventRecorder, +} from "../../../kit/parity"; +import { getPrismaClient, truncateAllTables } from "../../setup"; +import { makeCustomTag } from "../mood-tag-fixtures"; + +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 SHORT_FACTOR_ID = "mt_parity_short_factor"; + +let userId: string; +let owner: RouteAuth; +let binaryKey: string; +let rated: { key: string; scaleMax: number }; +let customKey: string; + +async function stored() { + const [entries, user] = await Promise.all([ + prisma.moodEntry.findMany({ + where: { userId }, + orderBy: [{ moodLoggedAt: "asc" }, { source: "asc" }], + select: { + id: true, + date: true, + tz: true, + mood: true, + score: true, + moodA1: true, + stressA2: true, + energyA3: true, + connectionA4: true, + stabilityA5: true, + tags: true, + note: true, + noteEncrypted: true, + source: true, + externalId: true, + moodLoggedAt: true, + deletedAt: true, + tagLinks: { + orderBy: { moodTag: { key: "asc" } }, + select: { moodTag: { select: { key: true } }, rating: true }, + }, + context: true, + }, + }), + prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { lastSyncedAt: true }, + }), + ]); + return { entries, user }; +} + +async function step(name: string, body: unknown): Promise { + wideEventRecorder.take(); + const res = await callRoute(POST, { + method: "POST", + route: "/api/mood-entries/bulk", + body, + auth: owner, + }); + const events = wideEventRecorder.take(); + expect(events.length).toBeGreaterThan(0); + await expectParity( + `mood-entries-bulk.${name}`, + withoutMintedKey( + stable( + { + status: res.status, + body: res.body, + events: events.map(eventFacts), + stored: await stored(), + }, + { since: NOW }, + ), + ), + ); +} + +/** + * The account's own tag carries a key minted at random (`custom:`); + * every occurrence is written as one placeholder so the snapshot is the same + * on each run. + */ +function withoutMintedKey(value: unknown): unknown { + return JSON.parse( + JSON.stringify(value).split(customKey).join(""), + ); +} + +function entry( + at: string, + mood: string, + extra: Record = {}, +): Record { + return { mood, moodLoggedAt: at, ...extra }; +} + +let firstDrain: Record[]; + +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); + const binary = await prisma.moodTag.findFirstOrThrow({ + where: { userId: null, kind: "BINARY", isActive: true }, + orderBy: { key: "asc" }, + select: { key: true }, + }); + binaryKey = binary.key; + // A catalogue factor on a scale shorter than the envelope's 1..5, so a + // rating the envelope admits can still fall outside the factor's scale. + // Every live catalogue factor runs 1..5, so this file adds one with a + // fixed key and removes it afterwards. + const ratedTag = await prisma.moodTag.create({ + data: { + id: SHORT_FACTOR_ID, + key: "factor_parity_short", + labelKey: "mood.tag.factorParityShort", + categoryId: "mtc_factors", + kind: "RATED", + scaleMin: 1, + scaleMax: 2, + }, + select: { key: true, scaleMax: true }, + }); + rated = ratedTag; + // Rated factors resolve against the catalogue only, so one the account + // made itself is reported as dropped. + const custom = await makeCustomTag(user, "sleep debt", { + kind: "RATED", + scaleMin: 1, + scaleMax: 2, + }); + customKey = custom.key; + firstDrain = [ + entry("2026-10-14T07:00:00.000Z", "GUT", { + externalId: "sd-1", + source: "WEB", + tags: ["slept well"], + tagKeys: [binaryKey, "no_such_tag"], + ratedFactors: [ + { key: rated.key, rating: 1 }, + { key: customKey, rating: 1 }, + ], + note: "a calm morning", + a2: 7, + a3: 4, + }), + entry("2026-10-14T19:30:00.000Z", "OKAY"), + entry("2026-10-13T21:00:00.000Z", "SCHLECHT", { + externalId: "sd-2", + context: { workStatus: "regular", workMinutes: 480 }, + }), + entry("2026-10-12T09:00:00.000Z", "SUPER_GUT", { + externalId: "sd-3", + ratedFactors: [{ key: rated.key, rating: rated.scaleMax + 1 }], + }), + entry("2026-10-12T10:00:00.000Z", "GUT", { + externalId: "sd-4", + context: { notAKey: true }, + }), + entry("2026-10-12T11:00:00.000Z", "GUT", { + externalId: "sd-5", + a4: 11, + }), + entry("2026-10-12T12:00:00.000Z", "GUT", { + externalId: "", + }), + ]; +}); + +afterAll(async () => { + vi.useRealTimers(); + await prisma.moodEntryTagLink.deleteMany({ + where: { moodTagId: SHORT_FACTOR_ID }, + }); + await prisma.moodTag.delete({ where: { id: SHORT_FACTOR_ID } }); +}); + +describe("POST /api/mood-entries/bulk parity", () => { + it("01 a first drain", async () => { + await step("01-first-drain", { entries: firstDrain }); + }); + + it("02 the same drain again", async () => { + await step("02-same-drain", { entries: firstDrain }); + }); + + it("03 a re-post of an entry the person deleted", async () => { + await prisma.moodEntry.updateMany({ + where: { userId, externalId: "sd-2" }, + data: { deletedAt: new Date("2026-10-14T20:00:00.000Z") }, + }); + await step("03-tombstone", { + entries: [ + entry("2026-10-13T21:00:00.000Z", "OKAY", { externalId: "sd-2" }), + ], + }); + }); + + it("04 the whole-batch refusals", async () => { + await step("04-too-large", { + entries: Array.from({ length: 501 }, (_, i) => + entry( + new Date(NOW.getTime() - (i + 1) * 3_600_000).toISOString(), + "OKAY", + ), + ), + }); + await step("05-invalid", { entries: [{ mood: "ECSTATIC" }] }); + }); +});