From 1340200cac08401dc54e4fdf9504e7421af0d90b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 09:58:14 +0100 Subject: [PATCH 1/3] test(e2e): hold the header height in every app language A translated label that runs a little longer than the English one wraps, and a wrapping header grows; every English-only check stays green while one locale ships a taller dashboard header. The new sweep switches its own account through each locale the app ships (via the locale API, with asserted before measuring), then compares the top bar and the dashboard's page-header row on a 390 px phone and a desktop window with the height English gives them, within 1 px. The locale list is read from the i18n config, so a new language is swept without editing the spec. It runs in the desktop project only because the account's language is one value shared by its tests. --- e2e/header-height-locales.spec.ts | 161 ++++++++++++++++++++++++++++++ e2e/setup/global-setup.ts | 49 +++++++++ playwright.config.ts | 5 + 3 files changed, 215 insertions(+) create mode 100644 e2e/header-height-locales.spec.ts diff --git a/e2e/header-height-locales.spec.ts b/e2e/header-height-locales.spec.ts new file mode 100644 index 000000000..e1697963a --- /dev/null +++ b/e2e/header-height-locales.spec.ts @@ -0,0 +1,161 @@ +/** + * The app header keeps its height in every language. + * + * A translated label that is a little longer than the English one wraps, and + * a header that wraps grows: the dashboard's title row once took a second + * line in one locale while every English check stayed green. The top bar and + * the dashboard's page-header row are measured here in every shipped locale, + * on a 390 px phone and on a desktop window, against the height English gives + * them on the same viewport. + * + * The language is switched the way the app switches it, through + * `PUT /api/auth/me/locale`, which writes the account's language and the + * locale cookie; `` is then asserted before anything is measured, + * so a switch that silently did not happen cannot pass as "same height". + * + * The locale list is the app's own (`src/lib/i18n/config.ts`), so a language + * added there is swept without touching this file. + */ +import type { Page } from "@playwright/test"; + +import { locales, type Locale } from "@/lib/i18n/config"; + +import { HEADER_LOCALES_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; + +const VIEWPORTS = [ + { label: "phone 390", width: 390, height: 844 }, + { label: "desktop 1280", width: 1280, height: 800 }, +] as const; + +type ViewportLabel = (typeof VIEWPORTS)[number]["label"]; + +interface HeaderGeometry { + topBar: number; + pageHeader: number; + /** For the failure message: what wrapped, not only that something did. */ + title: string; + titleHeight: number; +} + +/** English first: every other locale is compared with what it measured. */ +const ORDER: Locale[] = ["en", ...locales.filter((l) => l !== "en")]; + +const reference = new Map(); + +async function readHeader(page: Page): Promise { + return page.evaluate(() => { + const topBar = document.querySelector('[data-slot="top-bar"]'); + const rows = document.querySelectorAll( + 'main [data-slot="page-header-row"]', + ); + if (!topBar || rows.length !== 1) return null; + const row = rows[0]; + const title = row.querySelector("h1"); + return { + topBar: topBar.getBoundingClientRect().height, + pageHeader: row.getBoundingClientRect().height, + title: title?.textContent ?? "", + titleHeight: title?.getBoundingClientRect().height ?? 0, + }; + }); +} + +/** + * Read the header until two reads a frame apart agree. The greeting swaps to + * the personalised line after hydration; measuring mid-swap would compare a + * frame nobody keeps. + */ +async function settledHeader(page: Page): Promise { + await page.evaluate(() => document.fonts.ready.then(() => undefined)); + let previous: HeaderGeometry | null = null; + for (let attempt = 0; attempt < 20; attempt += 1) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + const current = await readHeader(page); + if ( + current && + previous && + current.topBar === previous.topBar && + current.pageHeader === previous.pageHeader + ) { + return current; + } + previous = current; + } + throw new Error( + `header did not settle: ${JSON.stringify(previous ?? "not found")}`, + ); +} + +test.describe("header height in every locale", () => { + test.use({ storageState: HEADER_LOCALES_STORAGE_STATE_PATH }); + // The account's language is one value; the locales take turns on it. + // It runs in the desktop project only (see `playwright.config.ts`) and sets + // both viewports itself. + test.describe.configure({ mode: "serial" }); + + for (const locale of ORDER) { + test(`top bar and dashboard header keep the English height in ${locale}`, async ({ + page, + }) => { + test.setTimeout(60_000); + + const switched = await page.request.put("/api/auth/me/locale", { + data: { locale }, + }); + expect(switched.status(), await switched.text()).toBe(200); + + for (const viewport of VIEWPORTS) { + await page.setViewportSize({ + width: viewport.width, + height: viewport.height, + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.locator("html")).toHaveAttribute("lang", locale); + await expect(page.locator('[data-slot="top-bar"]')).toBeVisible(); + await expect( + page.locator('main [data-slot="page-header-row"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="dashboard-header-greeting"]'), + ).toBeVisible(); + await expect + .poll(() => page.evaluate(() => window.innerWidth)) + .toBe(viewport.width); + + const measured = await settledHeader(page); + const at = `${locale}, ${viewport.label}`; + // A band that is not there measures 0 in every locale and "agrees". + expect( + measured.topBar, + `top bar has no height (${at})`, + ).toBeGreaterThan(32); + expect( + measured.pageHeader, + `page header has no height (${at})`, + ).toBeGreaterThan(20); + + if (locale === "en") { + reference.set(viewport.label, measured); + continue; + } + const english = reference.get(viewport.label); + expect(english, "the English reference ran first").toBeDefined(); + const detail = `title "${measured.title}" ${measured.titleHeight}px tall vs "${english!.title}" ${english!.titleHeight}px`; + expect( + Math.abs(measured.topBar - english!.topBar), + `top bar is ${measured.topBar}px in ${at}, ${english!.topBar}px in English`, + ).toBeLessThanOrEqual(1); + expect( + Math.abs(measured.pageHeader - english!.pageHeader), + `dashboard header is ${measured.pageHeader}px in ${at}, ${english!.pageHeader}px in English; ${detail}`, + ).toBeLessThanOrEqual(1); + } + }); + } +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 5a749b7fc..355535f32 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -325,6 +325,25 @@ export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateScoreHistory.json", ); +/** + * The header-height sweep's own account (`header-height-locales.spec.ts`). + * + * It moves the account's language through every shipped locale; the server + * honours `User.locale` wherever a request carries no locale cookie, so on a + * shared account a parallel spec could render in Korean halfway through. + */ +export const E2E_HEADER_LOCALES = { + email: "e2e-header-locales@healthlog.test", + username: "e2e-header-locales", + password: "Hl8!Tq2vNx6mWr4Z", + role: "USER", +} as const; + +export const HEADER_LOCALES_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateHeaderLocales.json", +); + export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateAiOptional.json", @@ -1102,6 +1121,33 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The header-height sweep's account. Default modules and layout, so the + // dashboard header carries what a fresh account's does; the language is + // put back to English because the sweep leaves it on its last locale. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json, locale) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL, 'en') + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = NULL, + dashboard_widgets_json = NULL, + locale = 'en'`, + [ + cuid(), + E2E_HEADER_LOCALES.username, + E2E_HEADER_LOCALES.email, + await hashPassword(E2E_HEADER_LOCALES.password), + now, + ], + ); + // The notification-dispatch journey's account. Seeded like the others; // its channels, devices, ledger rows and preferences are reset by // `e2e/setup/notification-fixture.ts` before every test, because the @@ -1784,6 +1830,9 @@ export default async function globalSetup(config: FullConfig): Promise { // The score-history journey's jar — see `E2E_SCORE_HISTORY`. await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH); + // The header-height sweep's jar — see `E2E_HEADER_LOCALES`. + await capture(E2E_HEADER_LOCALES, HEADER_LOCALES_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); diff --git a/playwright.config.ts b/playwright.config.ts index 4fbee7e5f..c046e2d21 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -310,6 +310,11 @@ export default defineConfig({ // other. It checks the phone width by resizing inside the desktop // project. "score-history.spec.ts", + // The header-height sweep moves its account's language through every + // locale; a second project would switch it under the first between + // the switch and the measurement. It sets the 390 px phone width + // itself inside the desktop project. + "header-height-locales.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs From 9d27b6f103efa94d2271b119df353e8c1694a282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 09:58:20 +0100 Subject: [PATCH 2/3] test(backup): restore every release's own backup file Every backup test exports and restores with the same code, so a change that moves both ends at once passes all of them and still breaks a file a self-hoster downloaded last month. Commit the disaster-recovery file the 1.46.1 exporter writes for the shared backup seed, with a manifest of its counts and SHA-256, and restore each committed file through the admin upload and restore routes into an empty account, comparing the re-exported counts section by section. The folder takes one file per release (GOLDEN_BACKUP_WRITE=1 writes the current one; the README says when). The seed's own illness-symptom catalogue key is swapped for a shipped one so every link resolves on a fresh instance. Ciphertext is sealed under the suite's fixed key. A measurement tombstone that ages past retention may only lower its count. --- .prettierignore | 5 + tests/fixtures/golden-backups/README.md | 44 +++ tests/fixtures/golden-backups/v1.46.1.json | 1 + .../golden-backups/v1.46.1.manifest.json | 72 +++++ tests/integration/golden-backups.test.ts | 271 ++++++++++++++++++ 5 files changed, 393 insertions(+) create mode 100644 tests/fixtures/golden-backups/README.md create mode 100644 tests/fixtures/golden-backups/v1.46.1.json create mode 100644 tests/fixtures/golden-backups/v1.46.1.manifest.json create mode 100644 tests/integration/golden-backups.test.ts diff --git a/.prettierignore b/.prettierignore index 87bd3e86c..8c2ba3c75 100644 --- a/.prettierignore +++ b/.prettierignore @@ -30,6 +30,11 @@ scripts/bench/results # parity test itself (BACKUP_PARITY_WRITE=1), so a rewrite diffs cleanly. tests/integration/__golden__ +# A release's backup file exactly as its exporter wrote it; the golden-backup +# test checks the bytes against the manifest's SHA-256, so it is never +# reformatted. The manifests are written by the same test. +tests/fixtures/golden-backups/*.json + # inlang writes its working files (cache, metadata, README) next to the # project settings when its CLI or SDK runs; only settings.json is tracked. project.inlang/* diff --git a/tests/fixtures/golden-backups/README.md b/tests/fixtures/golden-backups/README.md new file mode 100644 index 000000000..76eb4ca17 --- /dev/null +++ b/tests/fixtures/golden-backups/README.md @@ -0,0 +1,44 @@ +# Golden backups + +One disaster-recovery backup file per release, written by that release's own +exporter, and a manifest of what it held. `tests/integration/golden-backups.test.ts` +uploads every file here through the admin upload route, restores it into an +empty account through the restore route and job, exports the account again and +checks the counts section by section against the manifest. A backup a person +downloaded from an older release has to keep restoring after every later +change; this is where that is held still. + +| File | What it is | +| -------------------------- | --------------------------------------------------------------------------- | +| `v.json` | the file exactly as the exporter wrote it (compact JSON, never reformatted) | +| `v.manifest.json` | release, schema version, owner id, `exportedAt`, SHA-256, byte size, counts | + +The account is the shared backup seed (`tests/integration/backup-fixture.ts`: +one row of every two-ended model plus the column shapes), so every section the +backup writes holds at least one row. Ciphertext inside the file is sealed +under the integration suite's fixed key (`ENCRYPTION_KEY` in +`vitest.integration.config.mts`), so the file opens on every machine that runs +the suite and no real key is involved. + +## Adding the next release's file + +Once per release, on the release branch after the version bump, with Docker +running: + +```bash +GOLDEN_BACKUP_WRITE=1 pnpm vitest run --config vitest.integration.config.mts tests/integration/golden-backups.test.ts +pnpm vitest run --config vitest.integration.config.mts tests/integration/golden-backups.test.ts +``` + +The first command writes `v.json` and its manifest; +the second restores every file here, the new one included. Commit both files. + +Never rewrite an existing release's file: the test checks each file against the +SHA-256 in its manifest, because a file regenerated by today's exporter would +prove only that today's exporter and today's restore agree. When a change means +an old file can no longer restore as it was written, that is a breaking change +to the backup format, and it is decided in the restore code, not by replacing +the fixture. + +A count the restore is documented to drop by age (a measurement tombstone past +its retention) is listed in `AGES_OUT` in the test and may only go down. diff --git a/tests/fixtures/golden-backups/v1.46.1.json b/tests/fixtures/golden-backups/v1.46.1.json new file mode 100644 index 000000000..a3dd152fd --- /dev/null +++ b/tests/fixtures/golden-backups/v1.46.1.json @@ -0,0 +1 @@ +{"schemaVersion":"2","exportedAt":"2026-10-29T08:54:55.373Z","userId":"round-trip-owner","appSettings":null,"accountSettings":{"heightCm":null,"dateOfBirth":null,"gender":null,"hasDiabetes":false,"displayName":null,"fullName":null,"insurerName":null,"insuranceNumber":null,"insuranceNumberEncrypted":null,"insurerIkNumber":null,"lastReportPracticeName":null,"avatarBytes":null,"avatarContentType":null,"avatarUpdatedAt":null,"homeTimezone":null,"homeSince":null,"homeLocationEncrypted":null,"environmentAirQualityEnabled":true,"timezone":"Europe/Berlin","locale":null,"unitPreference":null,"glucoseUnit":null,"timeFormat":"AUTO","dateFormat":"AUTO","modulePreferencesJson":null,"thresholdsJson":null,"healthScoreConfigJson":null,"sourcePriorityJson":null,"dashboardWidgetsJson":null,"insightsLayoutJson":null,"medicationListLayoutJson":null,"moodTagLayoutJson":null,"documentsLayoutJson":null,"moduleListLayoutJson":null,"activityGoalsJson":null,"reportSelectionJson":null,"globalExcludedInjectionSites":[],"healthKitConfigJson":null,"notificationPrefs":null,"moodReminderEnabled":false,"aiProvider":null,"aiModel":null,"aiBaseUrl":null,"aiCompatBaseUrl":null,"aiCompatModel":null,"aiProviderChain":null,"aiResponseTimeoutSeconds":null,"useCentralCodex":false,"insightsPrivacyMode":"aggregated","insightsExcludeMetrics":[],"disableCoach":false,"aiUsageLevel":null,"coachPrefsJson":null,"documentsAutoAiRead":false,"labsLocalOcrEnabled":false,"storeWorkoutRoutes":false,"onboardingCompletedAt":null,"onboardingTourCompleted":false,"onboardingTourProgressJson":null,"disclaimerAcknowledgedAt":null,"disclaimerAcknowledgedVersion":null,"passkeyUpgradeNudgeDismissed":false},"measurements":[{"id":"cmvtb53ls002rvdovt5ne4m0v","type":"BLOOD_GLUCOSE","value":5.4,"valueMin":null,"valueMax":null,"unit":"mmol/L","measuredAt":"2026-07-03T06:45:00.000Z","source":"MANUAL","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":"FASTING","sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.360Z","updatedAt":"2026-10-29T08:54:55.360Z"},{"id":"cmvtb53lt002svdov5wtjes6d","type":"SLEEP_DURATION","value":95,"valueMin":null,"valueMax":null,"unit":"min","measuredAt":"2026-07-03T02:00:00.000Z","source":"APPLE_HEALTH","notes":null,"notesEncrypted":null,"externalId":"round-trip-deep-sleep","externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":"DEEP","rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.361Z","updatedAt":"2026-10-29T08:54:55.361Z"},{"id":"cmvtb53gu0001vdovkhes3wde","type":"PULSE","value":61,"valueMin":null,"valueMax":null,"unit":"bpm","measuredAt":"2026-07-02T06:30:00.000Z","source":"APPLE_HEALTH","notes":null,"notesEncrypted":null,"externalId":"round-trip-deleted-sample","externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":4,"deletedAt":"2026-10-19T08:54:54.000Z","createdAt":"2026-10-29T08:54:55.183Z","updatedAt":"2026-10-29T08:54:55.183Z"},{"id":"cmvtb53gq0000vdovpfeu4gu1","type":"WEIGHT","value":74.2,"valueMin":null,"valueMax":null,"unit":"kg","measuredAt":"2026-07-01T07:00:00.000Z","source":"MANUAL","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.178Z","updatedAt":"2026-10-29T08:54:55.178Z"},{"id":"cmvtb53gx0002vdovwm23zudk","type":"IRREGULAR_RHYTHM_NOTIFICATION","value":1,"valueMin":null,"valueMax":null,"unit":"event","measuredAt":"2026-06-15T14:22:00.000Z","source":"WITHINGS","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":"IRREGULAR","deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.185Z","updatedAt":"2026-10-29T08:54:55.185Z"}],"customMedicationCategories":[{"key":"custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10","label":null,"labelEncrypted":"djEueldXclgxVTZmcGhBaG9RUUFseFphVDloaWNLendKSWNtUGJZSmVTZkJ3RW5rbllZMm4zZklIZkYxc3ltamZ5VUx3PT0=","sortOrder":2,"isActive":true,"createdAt":"2026-10-29T08:54:55.214Z","updatedAt":"2026-10-29T08:54:55.214Z"}],"medications":[{"id":"cmvtb53hg0005vdovsxoh5965","treatmentClass":"GENERIC","dosesPerUnit":null,"unitsPerDose":"1","notificationsEnabled":true,"pausedAt":null,"snoozedUntil":null,"startsOn":"2026-06-10T00:00:00.000Z","endsOn":"2026-06-16T00:00:00.000Z","oneShot":false,"asNeeded":false,"deliveryForm":"ORAL","trackInjectionSites":false,"allowedInjectionSites":[],"liveActivityEnabled":false,"criticalAlarmEnabled":false,"atcCode":null,"rxNormCode":null,"lowStockNotifiedAt":null,"lowStockNotifiedThresholdDays":null,"reorderLeadDays":null,"externalSource":null,"externalId":null,"updatedAt":"2026-10-29T08:54:55.204Z","categoryUpdatedAt":"2026-10-29T08:54:55.215Z","name":"Round-trip tablet","dose":"5 mg","active":true,"trackIntake":true,"autoMarkTaken":false,"autoMarkTakenSince":null,"category":"custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10","createdAt":"2026-10-29T08:54:55.204Z","schedules":[{"id":"cmvtb53hi0006vdovqgyqa262","daysOfWeek":null,"timesOfDay":[],"reminderGraceMinutes":null,"rrule":null,"rollingIntervalDays":null,"scheduleType":"SCHEDULED","cyclicOnWeeks":null,"cyclicOffWeeks":null,"doseWindows":null,"unitsPerDose":null,"windowStart":"08:00","windowEnd":"09:00","label":"Morning","dose":null},{"id":"cmvtb53lx002uvdovsnxoik48","daysOfWeek":null,"timesOfDay":[],"reminderGraceMinutes":null,"rrule":null,"rollingIntervalDays":3,"scheduleType":"SCHEDULED","cyclicOnWeeks":null,"cyclicOffWeeks":null,"doseWindows":null,"unitsPerDose":null,"windowStart":"20:00","windowEnd":"21:00","label":"Every third evening","dose":null}],"sideEffects":[{"id":"cmvtb53ht000bvdov35cd70e8","notes":null,"notesEncrypted":"djEuK2o2NGsyTEkxWDdVQmhwUk1wVXpvdXNJbVltWnQ3TjVLWWtPUUtDSlZZL1g0Rm50Vk5yR0xpbDFOSmpycU9sdW15Rjg5N0grakN6RWxTTEplakN1T1ZKNjBRZHhRaFE9","createdAt":"2026-10-29T08:54:55.217Z","occurredAt":"2026-07-01T21:00:00.000Z","category":"GI","entry":"NAUSEA","severity":3}],"pauseEras":[{"id":"cmvtb53id000qvdovifgyuf1d","pausedAt":"2026-05-02T08:00:00.000Z","resumedAt":"2026-05-20T08:00:00.000Z","createdAt":"2026-10-29T08:54:55.237Z"},{"id":"cmvtb53id000rvdov2v4646gh","pausedAt":"2026-07-15T08:00:00.000Z","resumedAt":null,"createdAt":"2026-10-29T08:54:55.237Z"}],"ingredients":[{"id":"cmvtb53ib000pvdov8nmkzac0","updatedAt":"2026-10-29T08:54:55.235Z","nutrient":"vitamin_d","amountPerUnit":25,"unit":"ug","enteredAmount":1000,"enteredUnit":"IU","source":"LABEL_AI","createdAt":"2026-10-29T08:54:55.235Z"}],"courses":[{"startsOn":"2026-03-01","endsOn":"2026-03-07","endedAt":null,"note":null,"noteEncrypted":"djEuTFkxTWhOTmtoS2s0MENrV0RmZGpXejZuUTl0dTA1dUNncXhsZENiZWdwWEFNL0xIclpiWjZ1QT0=","updatedAt":"2026-10-29T08:54:55.204Z","createdAt":"2026-10-29T08:54:55.204Z"},{"startsOn":"2026-06-10","endsOn":"2026-06-16","endedAt":"2026-06-16T09:30:00.000Z","note":null,"noteEncrypted":null,"updatedAt":"2026-10-29T08:54:55.204Z","createdAt":"2026-10-29T08:54:55.204Z"}],"doseChanges":[{"id":"cmvtb53i6000kvdovs7x99n6e","note":null,"noteEncrypted":"djEuS1cwR1pjdEdnSk5qeFFvQWFoRGd2cVNBa3dVd3BKQ0UwV1hCbGx2YWhyV0pwbG5LL2M4cnp6bUlnR3BPVUc0TDA5QytmekMzU3ZoMXIxSzNndz09","effectiveFrom":"2026-04-01T08:00:00.000Z","doseValue":5,"doseUnit":"mg","createdAt":"2026-10-29T08:54:55.230Z"},{"id":"cmvtb53i6000lvdovbnuvm4u4","note":null,"noteEncrypted":null,"effectiveFrom":"2026-06-01T08:00:00.000Z","doseValue":10,"doseUnit":"mg","createdAt":"2026-10-29T08:54:55.230Z"}],"inventoryItems":[{"id":"cmvtb53i1000gvdov5ekq5hdq","notes":null,"notesEncrypted":"djEuYjAvR0xFZjQ4dEtibEw0RWEvZVhQRkk5WCtObkZuZmFmODZPYXVsM3c0OW9JMGpCYnNIMC9UTEtPSVpCeFFzWWdEMi9HanRrdmVyUzE0YnpHS09yY1U2SWxRPT0=","state":"ACTIVE","containerType":"BLISTER","unitsTotal":"30","unitsRemaining":"21","firstUseAt":"2026-07-01T08:00:00.000Z","expiresAt":"2027-01-31T00:00:00.000Z","printedExpiry":null,"purchasedAt":null,"manufacturer":"Fixture Pharma","doseStrength":"10 mg","createdAt":"2026-10-29T08:54:55.225Z","updatedAt":"2026-10-29T08:54:55.225Z"},{"id":"cmvtb53i3000hvdovje71pck7","notes":null,"notesEncrypted":null,"state":"USED_UP","containerType":"BOTTLE","unitsTotal":"60","unitsRemaining":"0","firstUseAt":null,"expiresAt":null,"printedExpiry":null,"purchasedAt":null,"manufacturer":null,"doseStrength":null,"createdAt":"2026-10-29T08:54:55.227Z","updatedAt":"2026-10-29T08:54:55.227Z"}],"phaseConfig":{"id":"cmvtb53hz000fvdovup5a7hlj","greenValue":45,"greenMode":"MINUTES","yellowValue":20,"yellowMode":"PERCENT","orangeValue":5,"orangeMode":"MINUTES","redValue":180,"redMode":"MINUTES"},"inventoryEvents":[{"id":"cmvtb53i4000ivdovqu7ez4ma","delta":30,"reason":"purchased","occurredAt":"2026-07-01T07:00:00.000Z"},{"id":"cmvtb53i4000jvdovzkdd0fed","delta":-8,"reason":"consumed","occurredAt":"2026-07-09T07:00:00.000Z"}],"scheduleRevisions":[{"id":"cmvtb53i7000mvdovd6am2lkd","validFrom":"2026-01-01T00:00:00.000Z","validUntil":"2026-03-01T00:00:00.000Z","payload":[{"dose":"5 mg","windowEnd":"09:00","windowStart":"07:00"}],"source":"ARCHIVED","supersededByIndex":null,"createdAt":"2026-10-29T08:54:55.231Z"},{"id":"cmvtb53ia000ovdov0qucjey6","validFrom":"2026-02-01T00:00:00.000Z","validUntil":"2026-03-01T00:00:00.000Z","payload":[{"dose":"10 mg","windowEnd":"13:00","windowStart":"12:00"}],"source":"ARCHIVED","supersededByIndex":2,"createdAt":"2026-10-29T08:54:55.234Z"},{"id":"cmvtb53i8000nvdovzac08ime","validFrom":"2026-03-01T00:00:00.000Z","validUntil":"2026-05-01T00:00:00.000Z","payload":[{"dose":"10 mg","windowEnd":"10:00","windowStart":"08:00"}],"source":"MANUAL","supersededByIndex":null,"createdAt":"2026-10-29T08:54:55.232Z"}],"efficacyTargets":[{"id":"cmvtb53iw000zvdov6thkng7z","createdAt":"2026-10-29T08:54:55.256Z","updatedAt":"2026-10-29T08:54:55.256Z","measurementType":null,"biomarkerName":"Ferritin","primary":true}]}],"intakeEvents":[{"id":"cmvtb53hn0009vdovi35yryxm","medicationId":"cmvtb53hg0005vdovsxoh5965","autoMissed":false,"attributionSource":"AUTO","idempotencyKey":null,"createdAt":"2026-10-29T08:54:55.211Z","injectionSite":null,"doseTaken":null,"inventoryConsumption":null,"externalId":null,"updatedAt":"2026-10-29T08:54:55.211Z","syncVersion":0,"deletedAt":null,"medication":"Round-trip tablet","scheduledFor":"2026-07-01T08:00:00.000Z","takenAt":"2026-07-01T08:04:00.000Z","skipped":false,"source":"WEB","autoRecorded":false}],"moodEntries":[{"note":null,"noteEncrypted":null,"syncedAt":"2026-10-29T08:54:55.245Z","syncVersion":0,"createdAt":"2026-10-29T08:54:55.245Z","updatedAt":"2026-10-29T08:54:55.245Z","linkCreatedAt":[{"key":"round_trip_factor","createdAt":"2026-10-29T08:54:55.245Z"}],"id":"cmvtb53il000uvdovcu6ymuyg","date":"2026-07-01","mood":"GUT","score":4,"a1":null,"a2":null,"a3":null,"a4":null,"a5":null,"tags":null,"source":"MOODLOG","loggedAt":"2026-07-01T20:00:00.000Z","externalId":null,"tz":null,"deletedAt":null,"factors":[{"key":"round_trip_factor","rating":4}],"structuredTags":[],"context":{"notesEncrypted":null,"createdAt":"2026-10-29T08:54:55.245Z","updatedAt":"2026-10-29T08:54:55.245Z","workStatus":"regular","workMinutes":null,"overtimeMinutes":null,"workLoad":null,"workSatisfaction":null,"contactCircles":"[\"family\"]","contactForm":null,"contactExtent":null,"contactQuality":null,"contactSupport":null,"leisureCategories":null,"leisureMinutes":null,"leisureJoy":6,"leisureRecovery":null,"eventType":null,"eventValence":null,"eventAt":null}}],"customMoodTagCategories":[{"id":"cmvtb53ih000svdovoooi2zsi","key":"round_trip_own_category","labelKey":"mood.category.roundTripOwn","icon":null,"sortOrder":90,"isActive":true,"labelEncrypted":null}],"hiddenMoodTags":[{"key":"grateful","createdAt":"2026-10-29T08:54:55.243Z"}],"customMoodTags":[{"id":"cmvtb53ii000tvdov8cpgf8mc","key":"round_trip_factor","labelKey":"mood.tag.roundTripFactor","categoryId":"cmvtb53ih000svdovoooi2zsi","kind":"RATED","isActive":true,"icon":null,"sortOrder":0,"labelEncrypted":null,"scaleMin":1,"scaleMax":5,"inverse":false}],"cycleProfile":{"id":"cmvtb53jm001fvdovvwlagz8v","createdAt":"2026-10-29T08:54:55.282Z","updatedAt":"2026-10-29T08:54:55.282Z","goal":"GENERAL_HEALTH","cycleTrackingEnabled":true,"typicalCycleLength":null,"typicalPeriodLength":null,"lutealPhaseLength":null,"secondarySymptom":"MUCUS","predictionEnabled":true,"rawChartMode":false,"discreetNotifications":false,"sensitiveCategoryEncryption":true},"cycles":[{"id":"cmvtb53jo001gvdova7ny0emd","isPredicted":false,"syncVersion":0,"deletedAt":null,"absorbedIntoId":null,"createdAt":"2026-10-29T08:54:55.284Z","updatedAt":"2026-10-29T08:54:55.284Z","startDate":"2026-06-01","endDate":null,"periodEndDate":null,"lengthDays":null,"ovulationDate":null,"ovulationConfirmed":false,"tz":null},{"id":"cmvtb53lv002tvdovdaqvxu56","isPredicted":false,"syncVersion":0,"deletedAt":"2026-06-04T07:00:00.000Z","absorbedIntoId":"cmvtb53jo001gvdova7ny0emd","createdAt":"2026-10-29T08:54:55.363Z","updatedAt":"2026-10-29T08:54:55.363Z","startDate":"2026-06-03","endDate":null,"periodEndDate":null,"lengthDays":null,"ovulationDate":null,"ovulationConfirmed":false,"tz":null}],"cycleDayLogs":[{"id":"cmvtb53js001ivdov4mosadw9","cycleId":"cmvtb53jo001gvdova7ny0emd","syncVersion":0,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.288Z","updatedAt":"2026-10-29T08:54:55.288Z","date":"2026-06-02","flow":"HEAVY","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,"sensitiveEncrypted":null,"notesEncrypted":null,"source":"MANUAL","externalId":null,"tz":null,"symptomKeys":["custom:round-trip-cramps"],"symptomSeverities":[],"symptomLinkCreatedAt":[{"key":"custom:round-trip-cramps","createdAt":"2026-10-29T08:54:55.288Z"}]}],"customSymptoms":[{"id":"cmvtb53jr001hvdovjkrbz30a","key":"custom:round-trip-cramps","labelKey":"cycle.symptom.custom","categoryId":"csc_physical","icon":null,"sortOrder":0,"isActive":true,"labelEncrypted":"v1.8mbBFQzIlXZlyOOb47BGgidX1a27u/aqtUo/iPUsT11Nbmi5btza6J6kEdwz"}],"labResults":[{"id":"cmvtb53iu000yvdov4ue4zxmu","biomarkerId":"cmvtb53ip000vvdovgljt63bk","noteEncrypted":null,"createdAt":"2026-10-29T08:54:55.254Z","updatedAt":"2026-10-29T08:54:55.254Z","deletedAt":null,"note":null,"panel":null,"analyte":"Ferritin","value":91,"valueText":null,"unit":"ng/mL","referenceLow":null,"referenceHigh":null,"sourceReferenceLow":null,"sourceReferenceHigh":null,"sourceReferenceText":null,"sourceValue":null,"sourceUnit":null,"takenAt":"2026-06-30T09:00:00.000Z","source":"MANUAL","biomarkerName":"Ferritin"}],"biomarkers":[{"id":"cmvtb53ip000vvdovgljt63bk","createdAt":"2026-10-29T08:54:55.249Z","updatedAt":"2026-10-29T08:54:55.249Z","name":"Ferritin","unit":"ng/mL","lowerBound":null,"upperBound":null,"panel":null,"hidden":false,"context":null,"contextEncrypted":null,"analyteKey":null,"aliases":[{"name":"Ferr.","source":"IMPORT_CHOICE","id":"cmvtb53ir000wvdov06y982y4","createdAt":"2026-10-29T08:54:55.251Z"}]}],"biomarkerMerges":[{"id":"cmvtb53is000xvdovbpinr8m5","targetBiomarkerId":"cmvtb53ip000vvdovgljt63bk","targetName":"Ferritin","sourceBiomarkerId":"merged-away-marker","sourceName":"Ferritin (Serum)","readingCount":3,"mergedAt":"2026-10-29T08:54:55.252Z"}],"illnessEpisodes":[{"id":"cmvtb53j00012vdovo647dare","label":"Cold","type":"INFECTION","lifecycle":"ACUTE","onsetAt":"2026-06-20T00:00:00.000Z","resolvedAt":null,"parentConditionId":null,"note":null,"noteEncrypted":null,"bodySite":null,"bodySiteEncrypted":null,"laterality":null,"createdAt":"2026-10-29T08:54:55.260Z","updatedAt":"2026-10-29T08:54:55.260Z","deletedAt":null,"dayLogs":[{"id":"cmvtb53j50015vdovh8qtkgpt","episodeId":"cmvtb53j00012vdovo647dare","date":"2026-06-21","functionalImpact":null,"feverC":null,"note":null,"noteEncrypted":null,"tz":null,"createdAt":"2026-10-29T08:54:55.265Z","updatedAt":"2026-10-29T08:54:55.265Z","deletedAt":null,"symptoms":[{"key":"body_aches","severity":2,"createdAt":"2026-10-29T08:54:55.265Z"}]}]},{"id":"cmvtb53j30013vdovy23rblep","label":"Iron deficiency","type":"CHRONIC","lifecycle":"ACUTE","onsetAt":"2026-03-01T00:00:00.000Z","resolvedAt":null,"parentConditionId":null,"note":null,"noteEncrypted":null,"bodySite":null,"bodySiteEncrypted":"djEueElmM2llZEo3RE9SMXRlV2V1OVplWHZlYWZ1NDBkbGlWUWtSdzQ2ZmlXa3BKRW89","laterality":"BOTH","createdAt":"2026-10-29T08:54:55.263Z","updatedAt":"2026-10-29T08:54:55.263Z","deletedAt":null,"dayLogs":[]}],"allergies":[{"id":"cmvtb53iy0010vdovjrthr6fr","substance":"Penicillin","category":"OTHER","type":"ALLERGY","severity":null,"status":"ACTIVE","onsetAt":null,"reaction":null,"note":null,"reactionEncrypted":null,"notesEncrypted":null,"createdAt":"2026-10-29T08:54:55.258Z","updatedAt":"2026-10-29T08:54:55.258Z","deletedAt":null}],"familyHistory":[{"id":"cmvtb53iz0011vdovz586kmm2","relationship":"MOTHER","condition":"Type 2 diabetes","ageAtOnset":null,"note":null,"notesEncrypted":null,"createdAt":"2026-10-29T08:54:55.259Z","updatedAt":"2026-10-29T08:54:55.259Z","deletedAt":null}],"workouts":[{"id":"cmvtb53k4001pvdov6zql4k0m","createdAt":"2026-10-29T08:54:55.300Z","updatedAt":"2026-10-29T08:54:55.300Z","externalSourceVersion":null,"sportType":"RUNNING","startedAt":"2026-06-29T06:00:00.000Z","endedAt":"2026-06-29T06:30:00.000Z","durationSec":1800,"totalEnergyKcal":null,"totalDistanceM":null,"avgHeartRate":null,"maxHeartRate":null,"minHeartRate":null,"stepCount":null,"elevationM":null,"pauseDurationSec":null,"source":"MANUAL","externalId":null}],"documents":[{"id":"cmvtb53k7001qvdovef32o89m","kind":"LAB_RESULT","title":"June labs","filename":"june-labs.pdf","mimeType":"application/pdf","byteSize":57,"status":"STORED","reportDate":null,"documentDate":null,"sourceSystem":"PAPERLESS","sourceId":"4711","sourceInstance":null,"createdAt":"2026-10-29T08:54:55.303Z","updatedAt":"2026-10-29T08:54:55.303Z","contentEncrypted":"AgJ2MRN4jZ1f5Utvmc0iNsVf7qTEmpaF3kwdTLWwR9Lu4bsqrzoVgzZ89pIt8Ev1v5KZn9FOgnB/","contentSha256":null,"contentCodec":"binary2","providerType":null,"errorReason":null,"summaryEncrypted":null,"summaryGeneratedAt":null,"summaryState":"NONE","lastIndexAttemptAt":null,"lastIndexOutcome":null,"aiReadDeferred":true,"extractionIncomplete":false}],"manifest":{"documents":{"included":"encrypted-content","note":"Document metadata and encrypted stored bytes are included for disaster recovery."},"workouts":{"included":"summary-only","note":"Workout summary records (duration, distance, heart rate, calories, steps) are included. GPS routes and per-sample heart-rate/pace time series are not included in this export."},"mentalHealth":{"included":"full","note":"Completed screener administrations are included with their encrypted per-item answers, for restore onto the instance that holds the key."},"consent":{"included":"full","note":"Consent receipts are included, for restore onto the instance the consent was given to."}},"healthProfile":{"id":"cmvtb53ji001dvdovy415uedg","aboutMe":null,"conditions":null,"allergies":null,"coachFocus":null,"aiIncludedSections":["ABOUT_ME","CONDITIONS","ALLERGIES","COACH_FOCUS","FAMILY_HISTORY","SMOKING_STATUS","ALCOHOL_PATTERN","SHIFT_SCHEDULE"],"lifeConfirmedJson":null,"emergencyBloodType":null,"organDonorStatus":null,"advanceDirectiveStatus":null,"emergencyContacts":null,"emergencyImplants":null,"emergencyNote":null,"emergencyContactsEncrypted":null,"emergencyImplantsEncrypted":null,"emergencyNoteEncrypted":null,"aboutMeEncrypted":"djEuaDBuMUUybXRBUEpCUEFFbGVVWFBuYURWenp0MjVRVmVkTGk2VVBhSXZFRFV2OWhTUi9ENE1hNlhXQU4zNmlzMDdhVWg=","conditionsEncrypted":null,"allergiesEncrypted":null,"coachFocusEncrypted":null,"pendingQuestionsEncrypted":null,"createdAt":"2026-10-29T08:54:55.278Z","updatedAt":"2026-10-29T08:54:55.278Z"},"healthProfileFacts":[{"id":"cmvtb53jk001evdovrc8gbfaz","kind":"SMOKING_STATUS","value":null,"valueEncrypted":"djEuRFVWQjVHdG9iOEYyWXU5SEtsVVNpY1Rib1FaTTB6TDBwRGdCMVA1Q3pDVDA=","validFrom":"2026-01-01T00:00:00.000Z","validUntil":null,"provenance":"USER_REPORTED","supersededByRevisionId":null,"createdAt":"2026-10-29T08:54:55.280Z"}],"customMetrics":[{"id":"cmvtb53jw001jvdovaieaxtmg","createdAt":"2026-10-29T08:54:55.292Z","updatedAt":"2026-10-29T08:54:55.292Z","deletedAt":null,"name":"Grip strength","unit":"kg","targetLow":null,"targetHigh":null,"decimals":null,"description":null,"correlationEnabled":false,"entries":[{"id":"cmvtb53jx001kvdova5pli5vm","createdAt":"2026-10-29T08:54:55.292Z","deletedAt":null,"noteEncrypted":null,"value":44,"unit":"kg","measuredAt":"2026-07-01T18:00:00.000Z","note":"left hand, after the long ride"},{"id":"cmvtb53jx001lvdovpvcoz3oy","createdAt":"2026-10-29T08:54:55.292Z","deletedAt":null,"noteEncrypted":"djEua0NvSGpXWDlFMzhORGt3Q1QrSTU2U29ONEVQL1lqNENTVTNvYmFqdkdaZUhHR0RmTTVoY1NmT0d3OWFwNUE9PQ==","value":45,"unit":"kg","measuredAt":"2026-07-02T18:00:00.000Z","note":null}]}],"correlationPatterns":[{"id":"cmvtb53jz001mvdovdqk86cbq","updatedAt":"2026-10-29T08:54:55.295Z","createdAt":"2026-10-29T08:54:55.295Z","canonicalKey":"p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1","family":"sleep","factorKey":"sleep_duration","outcomeKey":"weight","lagDays":1,"sampleSize":30,"effectSize":0.42,"pValue":0.01,"qValue":null,"evidenceHash":"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2","isCurrent":true,"lastComputedAt":"2026-07-01T00:00:00.000Z","dismissedAt":null,"dismissedEvidenceHash":null,"dismissedEffectSize":null,"dismissedSampleSize":null}],"decryptFailures":[],"intradayProfiles":[{"id":"cmvtb53h30004vdovtrvedtaf","createdAt":"2026-10-29T08:54:55.192Z","updatedAt":"2026-10-29T08:54:55.192Z","type":"ACTIVITY_STEPS","dateKey":"2026-07-01","hourlyCumulative":[0,350,700,1050,1400,1750,2100,2450,2800,3150,3500,3850,4200,4550,4900,5250,5600,5950,6300,6650,7000,7350,7700,8050],"dayTotal":8050,"sampleCount":24,"hourlyIncrements":[],"basis":"PER_SAMPLE","bucketCount":null,"timezone":"Europe/Berlin"}],"healthScoreRecords":[{"id":"cmvtb53k2001ovdovqhr4qjda","createdAt":"2026-10-29T08:54:55.298Z","dayKey":"2026-07-01","timezone":"Europe/Berlin","composite":71,"band":"green","scoreVersion":1,"composition":["activity","sleep"],"pillarScores":{"sleep":72,"activity":70},"inputFingerprint":"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3","configVersion":null,"configChangedAt":null,"computedAt":"2026-10-29T08:54:55.298Z"}],"onboardingRecord":{"id":"cmvtb53k1001nvdovs2o1i11f","createdAt":"2026-10-29T08:54:55.297Z","updatedAt":"2026-10-29T08:54:55.297Z","needs":{"recordTarget":"me","areas":["blood-pressure","sleep"],"medication":"yes","sources":["withings"],"visit":"within-a-month","units":{"glucoseUnit":"mmol/L","unitPreference":"metric"}},"steps":[{"id":"who","status":"done"},{"id":"areas","status":"done"},{"id":"medication","status":"pending"},{"id":"sources","status":"skipped"},{"id":"visit","status":"pending"},{"id":"units","status":"pending"},{"id":"confirm","status":"pending"},{"id":"first-result","status":"pending"},{"id":"done","status":"pending"}],"firstResult":{"task":"connect-source","target":"withings","completedAt":"2026-07-01T08:30:00.000Z"},"modulesDerivedAt":"2026-07-01T08:00:00.000Z","completedAt":"2026-07-01T08:00:00.000Z"},"practitioners":[{"id":"cmvtb53kq0022vdovglx3l39q","name":"Round-trip practice","specialty":"Internal medicine","practice":null,"location":null,"phone":null,"locationEncrypted":null,"phoneEncrypted":null,"noteEncrypted":"djEuRW82VkNSZ01WWEpjamZNQ3F2dG81UUoxeUg3aFEyMGIwbDcxeDRXKzRmd0VnMEQwdXN3ZURjb3VCQXVmRjFvPQ==","createdAt":"2026-10-29T08:54:55.322Z","updatedAt":"2026-10-29T08:54:55.322Z","deletedAt":null}],"encounters":[{"id":"cmvtb53kx0026vdov4izmrmau","occurredAt":"2026-06-30T08:00:00.000Z","status":"DONE","kind":"ROUTINE","practitionerId":"cmvtb53kq0022vdovglx3l39q","reasonEncrypted":"djEuSzhzVzB4MDNSTHlWNVFMSW1LTUY4dExwTFQ5Ty82RXNWWU4xWm9nQ2Z5RWF6NmVwYWROOWlnPT0=","outcomeEncrypted":"djEucE02NWpyb0k4bXFBOTVKQ0d4OUtXNDRETUMzdE1XUDdBV2dqLzVUK1JTMFk1UnUyTkw2UTNMN20xTW1nV0pnZHdQU3JtWEFhWmRmenpqd0dKbkxpN01yRVltbk0=","bodySiteEncrypted":null,"laterality":null,"reminderId":"cmvtb53ks0023vdovusvg5tmw","createdAt":"2026-10-29T08:54:55.329Z","updatedAt":"2026-10-29T08:54:55.329Z","deletedAt":null}],"encounterDocumentLinks":[{"encounterId":"cmvtb53kx0026vdov4izmrmau","targetId":"cmvtb53k7001qvdovef32o89m","createdAt":"2026-10-29T08:54:55.331Z"}],"encounterLabLinks":[{"encounterId":"cmvtb53kx0026vdov4izmrmau","targetId":"cmvtb53iu000yvdov4ue4zxmu","createdAt":"2026-10-29T08:54:55.333Z"}],"encounterConditionLinks":[{"encounterId":"cmvtb53kx0026vdov4izmrmau","targetId":"cmvtb53j00012vdovo647dare","createdAt":"2026-10-29T08:54:55.334Z"}],"customVaccines":[{"id":"cmvtb53l4002avdovsve6ad2o","name":"Round-trip travel combo","components":["typhoid","hepatitis-a"],"typicalSeriesDoses":2,"boosterIntervalMonths":36,"createdAt":"2026-10-29T08:54:55.336Z","updatedAt":"2026-10-29T08:54:55.336Z","deletedAt":null}],"vaccinations":[{"id":"cmvtb53l5002bvdovcmj0wbnz","occurredAt":"2026-05-14T00:00:00.000Z","antigenSlug":"tdap","vaccineName":"Tetanus, diphtheria and pertussis","doseNumber":3,"seriesDoses":3,"lotNumber":"RT-4471","site":"LEFT_ARM","practitionerId":"cmvtb53kq0022vdovglx3l39q","encounterId":"cmvtb53kx0026vdov4izmrmau","reminderId":"cmvtb53ks0023vdovusvg5tmw","customVaccineId":"cmvtb53l4002avdovsve6ad2o","noteEncrypted":"djEueHFoTlJ3OWg5a0Rta2pNVzloLzVzM3NGdXBaN0p4WXJvUzlUZTlKWFRUaGQ5azNTQXhKc0kvU1ZHS1ZJN3ErcWhkT1JBUzUvdnYxZVdCdWo=","createdAt":"2026-10-29T08:54:55.337Z","updatedAt":"2026-10-29T08:54:55.337Z","deletedAt":null}],"vaccinationDocumentLinks":[{"vaccinationId":"cmvtb53l5002bvdovcmj0wbnz","targetId":"cmvtb53k7001qvdovef32o89m","createdAt":"2026-10-29T08:54:55.340Z"}],"measurementReminders":[{"id":"cmvtb53ks0023vdovusvg5tmw","label":"Blutdruck messen","measurementType":"BLOOD_PRESSURE_SYS","intervalDays":7,"rrule":null,"anchorDate":null,"endsOn":null,"origin":"VORSORGE","notifyHour":8,"location":"Zuhause","nextDueAt":"2026-07-08T06:00:00.000Z","lastSatisfiedAt":"2026-07-01T06:05:00.000Z","enabled":true,"vaccinationAntigen":"tetanus","snoozedUntil":"2026-07-08T06:00:00.000Z","lastSkippedAt":"2026-06-24T06:00:00.000Z","skipCount":2,"createdAt":"2026-10-29T08:54:55.324Z","updatedAt":"2026-10-29T08:54:55.324Z","deletedAt":null}],"measurementReminderEvents":[{"id":"cmvtb53kv0025vdove9yhkl10","reminderId":"cmvtb53ks0023vdovusvg5tmw","kind":"SKIPPED","occurredAt":"2026-06-24T06:00:00.000Z","onTime":false,"source":"skip","createdAt":"2026-10-29T08:54:55.327Z"},{"id":"cmvtb53ku0024vdovb80761w2","reminderId":"cmvtb53ks0023vdovusvg5tmw","kind":"SATISFIED","occurredAt":"2026-07-01T06:05:00.000Z","onTime":true,"source":"manual","createdAt":"2026-10-29T08:54:55.326Z"}],"coachConversations":[{"id":"cmvtb53kc001uvdov8fah7j7u","title":"How is my blood pressure trending?","documentScoped":false,"summaryEncrypted":"djEuWlhWQVZYU1dFTytCaE1WakZ0RktOMzVmU3VTc2ZMTldlakxleGNuRStycUY3ZVZuWmdtTm56YlIxYzYrYzdxaFdSWHRIRTNmaFlnczN3ZDFlRVMyRlExeGhUZzlXdXF3L3c9PQ==","summaryUpdatedAt":"2026-07-20T10:00:00.000Z","summaryTurnCount":4,"createdAt":"2026-10-29T08:54:55.308Z","updatedAt":"2026-10-29T08:54:55.308Z","messages":[{"id":"cmvtb53kd001vvdovy7y0pfry","role":"user","contentEncrypted":"djEuWHdpL2IzK3B4ZXhPYmFiQ3B6akpXN0ZlTHEwbVhlMWVkTk5kbWVhMzRvNTREME1DRVg1a2JPTnhCOWxrNGl0Nm9oYmF5ZmpMbTg0VlZxSU5HY2pobFhtNVRCU0ZsY0FXenptcVN3PT0=","metricSourceJson":null,"providerType":null,"promptVersion":null,"tokensUsed":null,"model":null,"resultsEncrypted":null,"trailEncrypted":null,"createdAt":"2026-07-20T09:58:00.000Z"},{"id":"cmvtb53kd001wvdovcdswvoip","role":"assistant","contentEncrypted":"djEuNmY4cE1jRmtobVBhUGhCSk9sZ1RCRUxpdXBNbWZNa2VSazgwQTRmcUwrWm10Vm41L2lOQTJWc0NlbktMaWdBZ3F3Z1hyNVphdU9FWUlUOXIyRjJldTRIcnlDSUNpMWVTR0wzdkY5WmxnZGhZZTFVYzA5aVd6ZUhTNjI5RA==","metricSourceJson":null,"providerType":"anthropic","promptVersion":null,"tokensUsed":812,"model":"claude-opus-5","resultsEncrypted":"djEuOXlFREdHZEtFVGpmMkVpRHJEQWVRVWpRRUpBb2ZyZTdqZWh3UEtoTmdlT2l0dUVNVXl0T0pETVAwbzhHUlhodGljenZSU1NGSlJINXJWUTBzTzR2cTFKeUtFZXVhWHZEWTg5Zk9OaFVvQUtDUUJRL25TUWh3SFMzc0xuZEtNOVFGL1pSUzNEQ1VDL3FaaVo4eExPSy95NGl3by9ESVVCZUozcHNMN0luanFkb2J4ZVljOHN0bGRhUHFxNVREOXJ3KytFZzZZbERGTE5Qak4wMllaeDFwTnUzajZpRDk0OWN4M2d3SmdQTVNKZ0xhUHc5cHZqU0lpQnR3L05zQ2p6SDhZdTVFb3hWdGtyaGVHMXVjUjNlZDZUcUFvSWMvb1JBbzhGWUt6K2didkFtNExJK2tIamZHMzNiK0VuMFNScVdBR2RxTFYwTE5VQ255OTY3MUdXMHo0U2xlcnFMa3h4WVNWaDVGNktpZjBTOEd5bjJ0eVlnNWFBYTY0RXU0eElzaFN6UVBXNENqNTFlSnMzSUYrdmVKQ2M5YVBLUHFYM0ZuTVRBOXFOaUw5Q2Y1elE1N1ZjbGRjc0Fpc2pTQU9VejQrMFk2SUlMMG5DUmE4Vyt4RTA2c1M2eUpaTXpsM3pSOVFlU1ZRTWFBNDErcExOZDdyWmt4dVpubnR2dk1KbjNMN0xwcjRaUDIyU0tqS0hNTDJjbmVoMVQ0djZpVlFuY1BFcTk1SUw1SFFEaDl2YXdGQmJQaWdzTklJNXl5NlBKL25xU1N1TGlEUXRmSnBHQ1FBU1RRQUhHV0l5MDhNd3JWVUd0WVM2ckN1S3B6dE1ycDN4cHM5WVgwR2hwNHUwaURMdVNWeGpLc3RzQThRWmRhYjFBNjRaNEs1SHk3Yms5Mll3UFgybGZoMWp1dlR3VTNGa0w0OUR4VXYwSW1LRUhhK2hIOU9ZTC8wVFlwSUF6Uk05NzU2azZKM2I4a3Zna1cwaDh6TVBJVXQ3QlJCNXRYTFBXQndubnI1TkRnckR4aE8xRE45WThsY3JnZGEwK1BVWUFORlV0SDRIWmxaRThHSkpFeXVURzVZT3FtejYyU0hkYTBieVdwaWFPUnhWQw==","trailEncrypted":"djEuUWZmVytYUUI4ZnVnV0p0bkJzbTR5OUJUMFhMSWp4NXdXNiszMXZENnNVUFJwbzZWemFuZU9YblBZOGZXVXBXMmpYdzloc2hvbG45RjFQZXNWQzJvdWVhYUFGUitqbzZ3OFYrMzZ4MGxmRWRYWEU4akc5WVd1WHQwcDhDS2kxeElEYWpBWXVPcitrTVo=","createdAt":"2026-07-20T09:59:00.000Z"}],"attachments":[]},{"id":"cmvtb53kg001xvdov8dzaduah","titleEncrypted":"djEuK0FkU2dWWlNTR01TOEVjbmlrMWRLd2NjTHNsdW93aFVtN1hFZTh6bXMvSTgvcnE5WC9Zc2JsOWFDYWw4T1E9PQ==","documentScoped":true,"summaryEncrypted":null,"summaryUpdatedAt":null,"summaryTurnCount":0,"createdAt":"2026-10-29T08:54:55.312Z","updatedAt":"2026-10-29T08:54:55.312Z","messages":[{"id":"cmvtb53kg001yvdov1i9hsrnv","role":"user","contentEncrypted":"djEuaWJ4YlB4T1QweHl5MWQ5eUY0ZXk1K2xMSktHR1ZmaVJRUXBsbVpOLzAwaFRNN3IrR1QvY3NUYXF1clcvcXI5alBNZHNtU2xFaDZ3PQ==","metricSourceJson":null,"providerType":null,"promptVersion":null,"tokensUsed":null,"model":null,"resultsEncrypted":null,"trailEncrypted":null,"createdAt":"2026-07-21T08:00:00.000Z"}],"attachments":[{"documentId":"cmvtb53k7001qvdovef32o89m","addedAt":"2026-07-21T07:59:00.000Z"}]}],"coachFacts":[{"id":"cmvtb53kl001zvdov4h6ja829","factEncrypted":"djEuTTd5bXNDSWRCUTJlc2VOVUtGVkx2dlZ2K1NwakFEYTN3YmlFS3huV1BaMk04TWVqeVYxYWhTTjlodjVzazJ5QkxCcnJTNEJkRlJyMnQ0b04=","deletedAt":null,"category":"constraint","confidence":80,"sourceConversationId":"cmvtb53kc001uvdov8fah7j7u","source":"user","lastUsedAt":"2026-07-20T10:00:00.000Z","sourceMessageId":"cmvtb53kd001wvdovcdswvoip","createdAt":"2026-10-29T08:54:55.317Z","updatedAt":"2026-10-29T08:54:55.317Z"}],"coachPlans":[{"id":"cmvtb53kn0020vdov72t5rsxp","metric":"BLOOD_PRESSURE","ifCueEncrypted":"djEuVmlkaFdqZU1RM25ORXdSTzRja3FzbFVTalhMNVlKSktpRHRneHovQk40RGhOdTdBU2ZFbGNCdm5GYVdvT1BxNU8vT3dUVHVwY1dkYVZCSmtYRlE9","thenActionEncrypted":"djEuNE5pZzhmcFIxTExCeXIwbzM0YXRCV2hjbU1kdHA3Uk94bitVbGVTT25Jd21OS3d1Y2xDc3duait6U1NGazRwZ0xCZ2Qra0c2MVN6VmJKdmtuaXdFYU10NGtQWENQMitxT1JrRA==","targetEncrypted":"djEuV1hRN211SXNuSzdYVXo1S2tMc3VoekhUd1BKTlVCUnpaZWhWdndCSHF4a0xEU3Y0eXJXRnVRSDFRVllaTThoSA==","outcomeEncrypted":null,"deletedAt":null,"status":"active","reviewDate":"2026-10-01T00:00:00.000Z","sourceConversationId":"cmvtb53kc001uvdov8fah7j7u","createdAt":"2026-10-29T08:54:55.319Z","updatedAt":"2026-10-29T08:54:55.319Z"}],"coachReminders":[{"id":"cmvtb53ko0021vdovecgk4vtu","noteEncrypted":"djEuOVNYeUx3cnZTZFJwR3VtY0RKakNLM2tkRnpaMkNTTTNrRnBSaVpuSU1tOS8rdGhzVDhjWjFtY2djMnp3UmJjNU1GSUcxYURKN3p0blZVaHNsb0Uw","deletedAt":null,"metric":"BLOOD_PRESSURE","relatedPlanId":"cmvtb53kn0020vdov72t5rsxp","triggerKind":"date","dueAt":"2026-09-01T07:00:00.000Z","contextCue":null,"status":"active","source":"extractor","sourceConversationId":"cmvtb53kc001uvdov8fah7j7u","lastSurfacedAt":null,"surfaceCount":2,"createdAt":"2026-10-29T08:54:55.320Z","updatedAt":"2026-10-29T08:54:55.320Z"}],"mentalHealthAssessments":[{"id":"cmvtb53hv000cvdovj7bogb64","instrument":"PHQ9","locale":"de","wording":null,"version":"standard","responsesEncrypted":"djEuS2drOHZFa0grR3BBeTUxeko4Vk5yU2xhQVU0RkxaS0dtMDgyUFNvTjR0SmFRT2JVcTJKQkp6ZWdRUjQ0V3dKUktqUUJhMFdKNVkvWXFZelpSK2lvV3Vnbm1Zaz0=","totalScore":14,"severityBand":"moderate","item9Flagged":true,"crisisShownAt":"2026-07-05T18:02:00.000Z","takenAt":"2026-07-05T18:00:00.000Z","tz":"Europe/Berlin","source":"WEB","externalId":null,"syncVersion":0,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.219Z","updatedAt":"2026-10-29T08:54:55.219Z"}],"consentReceipts":[{"id":"cmvtb53hx000dvdovj3s98ayf","kind":"ai-processing","artefact":"JVBERi0xLjQKJWZpeHR1cmU=","signedAt":"2026-06-01T09:00:00.000Z","revokedAt":null,"createdAt":"2026-10-29T08:54:55.221Z"},{"id":"cmvtb53hx000evdovl7aem43m","kind":"research-sharing","artefact":"eyJhbGciOiJub25lIn0.e30.","signedAt":"2026-06-02T09:00:00.000Z","revokedAt":"2026-07-10T11:00:00.000Z","createdAt":"2026-10-29T08:54:55.221Z"}],"documentConditionLinks":[{"documentId":"cmvtb53k7001qvdovef32o89m","episodeId":"cmvtb53j30013vdovy23rblep","createdAt":"2026-07-02T09:00:00.000Z"}],"extractedFacts":[{"id":"cmvtb53ka001tvdov6syhx96f","documentId":"cmvtb53k7001qvdovef32o89m","factType":"OBSERVATION","status":"APPROVED","confidence":0.94,"needsReview":false,"committedRecordId":"cmvtb53iu000yvdov4ue4zxmu","committedRecordType":"labResult","dataEncrypted":"djEuS2xNQWliSHRPdW1RektpOTFuZE1ZL2g5bWRmTThsb21DeVpZckZzSExWRG42QVlHOHVmYlZjWFl3NnY5UVZYVTdlcjNqL25zMFFqeFBnaGY5eWQxQlRiMkdGT2RxQmI1bkxyczUvWWtEdWdwVElhYUFJQ25NMHBiWVloZXNyTG5ZZ1JtendPaGp2OTNHYU9uUFB4MHlHTjVLVVY5WXBEY0Zwa0tsR1AyNEw0cjN0UnJnSStXb3dqcG82VzlnNmFpd2ZFWFBnTE1NK2JWL0NJVXY3WDRFNzdWTHVoVlJ1U00xMDMybnhQUUdHOUdIbERNbkx6NEdYRXhtblU9","provenanceEncrypted":"djEuMGZraTNxVlNMVFVnM1dURGU4OHcrR0x1YS85N0Y5OHZsR2lGeW5QN3B0QnZBSFBEL01mY2JvMmpnZ2M4b0hjQzk1WXo4akplczhmQnBUSnhqb3pVSGxtNE9KaWlKR2RZMHJIZ1YzeC9JeWFtUzl1Q2dXS2ZTRm5TS1plM0xGU0svSU9xMjlOblNTYVRncDFST2dUKzBlcVlZZmFEOW80Rnh5eFhzT2g0a3lMbmFKbG1BTHpKR3N3Zw==","createdAt":"2026-10-29T08:54:55.306Z","updatedAt":"2026-10-29T08:54:55.306Z"}],"documentImportKeys":[{"sourceSystem":"PAPRA","sourceId":"doc_deleted","sourceInstance":"https://papra.example","createdAt":"2026-07-03T09:00:00.000Z"}],"personalRecords":[{"metricType":"WALKING_RUNNING_DISTANCE","metricSlot":"running_5km_time","direction":"MIN","value":1512,"unit":"s","achievedAt":"2026-05-18T09:20:00.000Z","sourceMeasurementId":null,"source":"APPLE_HEALTH","externalId":"workout-pr-5km","createdAt":"2026-10-29T08:54:55.351Z"},{"metricType":"WALKING_RUNNING_DISTANCE","metricSlot":"running_10km_time","direction":"MIN","value":3184,"unit":"s","achievedAt":"2026-05-18T09:20:00.000Z","sourceMeasurementId":null,"source":"APPLE_HEALTH","externalId":"workout-pr-10km","createdAt":"2026-10-29T08:54:55.351Z"},{"metricType":"WEIGHT","metricSlot":null,"direction":"MIN","value":74.2,"unit":"kg","achievedAt":"2026-07-01T07:00:00.000Z","sourceMeasurementId":"cmvtb53gq0000vdovpfeu4gu1","source":"MANUAL","externalId":null,"createdAt":"2026-10-29T08:54:55.349Z"}],"userAchievements":[{"achievementId":"retired-badge-from-an-older-release","unlockedAt":"2025-11-05T08:00:00.000Z","createdAt":"2026-10-29T08:54:55.352Z"},{"achievementId":"intake-total-10","unlockedAt":"2026-02-21T10:15:00.000Z","createdAt":"2026-10-29T08:54:55.352Z"}],"environmentContexts":[{"date":"2026-06-15","lat":null,"lon":null,"locationEncrypted":"AgJ2MbXhSp6lMWU1TXuhMFOGEI/p/GzkDiD6Q5GFU/VyqBb5uxQGVcNloC44XX9ZMwilNlInsX2hRdFQg0EXZfukWFBlhGA7clg=","locationLabel":null,"source":"TRAVEL","tempMin":19.4,"tempMax":28.1,"tempMean":23.6,"apparentMean":25.2,"sunshineSec":39600,"daylightSec":52800,"precipSum":0,"pressureMean":1016.4,"pressureDelta":3.2,"humidityMean":63,"cloudMean":12,"weatherCode":1,"apparentMax":null,"pm25Mean":null,"pm25Max":null,"pm10Mean":null,"no2Mean":null,"so2Mean":null,"coMean":null,"o3Max8h":null,"eaqiMax":null,"usaqiMax":null,"uvIndexMax":null,"dustMax":null,"aodMax":null,"pollenAlderMax":null,"pollenBirchMax":null,"pollenGrassMax":null,"pollenMugwortMax":null,"pollenOliveMax":null,"pollenRagweedMax":null,"aqDomain":null,"aqHours":null,"aqFetchedAt":null,"fetchedAt":"2026-06-16T03:15:00.000Z","createdAt":"2026-10-29T08:54:55.358Z","updatedAt":"2026-10-29T08:54:55.358Z"},{"date":"2026-07-01","lat":null,"lon":null,"locationEncrypted":"AgJ2MWO2J3gLCu/NV1ZLnKPEpy+dSV78aNIbIcpf1ekFIMTTdEzBdTGJf7CfS4b+l2Hj1+dP2zum0jxFToc9v2PVslHqzmE2","locationLabel":null,"source":"HOME","tempMin":13.1,"tempMax":22.7,"tempMean":17.9,"apparentMean":17.1,"sunshineSec":28800,"daylightSec":59400,"precipSum":4.6,"pressureMean":1008.9,"pressureDelta":7.8,"humidityMean":74,"cloudMean":68,"weatherCode":61,"apparentMax":null,"pm25Mean":null,"pm25Max":null,"pm10Mean":null,"no2Mean":null,"so2Mean":null,"coMean":null,"o3Max8h":null,"eaqiMax":null,"usaqiMax":null,"uvIndexMax":null,"dustMax":null,"aodMax":null,"pollenAlderMax":null,"pollenBirchMax":null,"pollenGrassMax":null,"pollenMugwortMax":null,"pollenOliveMax":null,"pollenRagweedMax":null,"aqDomain":null,"aqHours":null,"aqFetchedAt":null,"fetchedAt":"2026-07-02T03:15:00.000Z","createdAt":"2026-10-29T08:54:55.358Z","updatedAt":"2026-10-29T08:54:55.358Z"}],"environmentTravelLocations":[{"startDate":"2026-06-10","endDate":"2026-06-20","lat":null,"lon":null,"label":null,"locationEncrypted":"AgJ2MX1s5xI8/5GCwXr0dGcxCXT27eCDPtouO8MJYML9fzugGzM3qGD8+KJxWjyEpfaRWWY5v6IFHVuSQPaf/hASsKTAE9HgSks=","createdAt":"2026-10-29T08:54:55.356Z","updatedAt":"2026-10-29T08:54:55.356Z"}],"dawarichDays":[{"date":"2026-06-15","totalDistanceM":48300,"drivingDistanceM":12000,"trainDistanceM":30500,"placesVisited":4,"awayFromHomeMinutes":1440,"countryCode":null,"countryCodeEncrypted":"AgJ2MeQdvkUUZB/ZLMxcWHEtdFgtLQLmfSViDob5oieYtw==","isTravelDay":true,"fetchedAt":"2026-06-16T03:20:00.000Z","createdAt":"2026-10-29T08:54:55.354Z","updatedAt":"2026-10-29T08:54:55.354Z"}],"ecgRecordings":[{"id":"cmvtb53h00003vdovq2wqwtk1","waveformEncrypted":"djEuclAvMU5Va0RyK29lM1FPZlB3bUF5TmxPa3kxRDRVc3l1Vkl1eTlBUmpRWGJhL0J6dWNvbEZ4RDdRZ0grdVhwbzI2T3hxMFk9","source":"WITHINGS","externalRecordingId":"round-trip-signal-1","recordedAt":"2026-06-15T14:22:00.000Z","samplingFrequency":300,"sampleCount":8,"durationSeconds":0.02666666666666667,"lead":"I","averageHeartRate":88,"rhythmClassification":"IRREGULAR","measurementId":"cmvtb53gx0002vdovwm23zudk","createdAt":"2026-10-29T08:54:55.188Z","updatedAt":"2026-10-29T08:54:55.188Z"}],"symptomDefinitions":[{"id":"cmvtb53j80016vdov4akhlf91","labelEncrypted":"djEuYVhIKzYxUnhyWmxWSkZWdHErMGRBcDJ6SnJydTRDNnNoQ3JFQXZPOE9wMD0=","icon":"Zap","sortOrder":0,"isActive":true,"createdAt":"2026-10-29T08:54:55.268Z","updatedAt":"2026-10-29T08:54:55.268Z","events":[{"id":"cmvtb53jb0018vdov60tla80k","noteEncrypted":null,"occurredAt":"2026-06-21T09:00:00.000Z","intensity":4,"episodeId":"cmvtb53j00012vdovo647dare","createdAt":"2026-10-29T08:54:55.271Z","updatedAt":"2026-10-29T08:54:55.271Z"},{"id":"cmvtb53jf001avdovnilf5n1w","noteEncrypted":null,"occurredAt":"2026-07-10T18:30:00.000Z","intensity":2,"episodeId":null,"createdAt":"2026-10-29T08:54:55.275Z","updatedAt":"2026-10-29T08:54:55.275Z"}]},{"id":"cmvtb53ja0017vdovi92xsn35","labelEncrypted":"djEuQlQrRnZSai85S3hqc3Rid1BqZUJHWVlqc3NXM2NYN1k5N2pwcjVEZ1p4Z3NkQmxW","icon":"Brain","sortOrder":1,"isActive":false,"createdAt":"2026-10-29T08:54:55.270Z","updatedAt":"2026-10-29T08:54:55.270Z","events":[{"id":"cmvtb53je0019vdovlxttag4s","noteEncrypted":"djEub3BFNVdCb1pGNWJLNjlzUktoV2lEd1cyLzNjSDU4ejB2dkhYNkpCcFlySXU0Q1ptc3ZyK0NJOS9GNlU3Nk45cFFZckorQ1k9","occurredAt":"2026-06-21T11:00:00.000Z","intensity":8,"episodeId":null,"createdAt":"2026-10-29T08:54:55.274Z","updatedAt":"2026-10-29T08:54:55.274Z"}]}],"lifeEvents":[{"id":"cmvtb53jg001bvdovtrqgpazo","titleEncrypted":"djEuMHRBdXpGTXlFZjVienhsT3lhMUw2NEV4aHZOUDR2WkZWeU8xNG53bERJWUdUejhleEhudlNFU1NuN1RNOGc9PQ==","noteEncrypted":"djEuU0hqRCs2eG5FU0xHREtVZ29DUlUyK2hwS0hFQXUxa0RCeDF6U1RkcEJXM1NIM2F2L2g3Z283UVIwR0ZWNXJBaGI5bVFZMkhrVmtGcVBnPT0=","category":"HOME","startDate":"2023-09-01","endDate":null,"precision":"MONTH","createdAt":"2026-10-29T08:54:55.276Z","updatedAt":"2026-10-29T08:54:55.276Z","deletedAt":null},{"id":"cmvtb53jh001cvdovadcqnp6r","titleEncrypted":"djEuK0pFUFB2T0QrbHMxUWpNLzRVdS9uSHY1MEtyaGhjRFVoeDA1M2VrTlZPM2ZXMHI4S1hkZWUvNHk=","noteEncrypted":null,"category":"WORK","startDate":"2026-03-02","endDate":"2026-05-29","precision":"DAY","createdAt":"2026-10-29T08:54:55.277Z","updatedAt":"2026-10-29T08:54:55.277Z","deletedAt":null}],"labResultDocumentLinks":[{"labResultId":"cmvtb53iu000yvdov4ue4zxmu","documentId":"cmvtb53k7001qvdovef32o89m","createdAt":"2026-10-29T08:54:55.342Z"}],"encounterMedicationLinks":[{"encounterId":"cmvtb53kx0026vdov4izmrmau","medicationId":"cmvtb53hg0005vdovsxoh5965","createdAt":"2026-10-29T08:54:55.343Z"}],"encounterVaccinationLinks":[{"encounterId":"cmvtb53kx0026vdov4izmrmau","vaccinationId":"cmvtb53l5002bvdovcmj0wbnz","createdAt":"2026-10-29T08:54:55.344Z"}],"medicationDocumentLinks":[{"medicationId":"cmvtb53hg0005vdovsxoh5965","documentId":"cmvtb53k7001qvdovef32o89m","createdAt":"2026-10-29T08:54:55.346Z"}],"medicationConditionLinks":[{"medicationId":"cmvtb53hg0005vdovsxoh5965","episodeId":"cmvtb53j00012vdovo647dare","createdAt":"2026-10-29T08:54:55.348Z"}],"nutrientDays":[{"day":"2026-07-01","nutrient":"WATER","amount":2100,"unit":"ml","source":"MANUAL","externalSourceVersion":null,"createdAt":"2026-10-29T08:54:55.301Z","updatedAt":"2026-10-29T08:54:55.301Z"}]} \ No newline at end of file diff --git a/tests/fixtures/golden-backups/v1.46.1.manifest.json b/tests/fixtures/golden-backups/v1.46.1.manifest.json new file mode 100644 index 000000000..7fed6251f --- /dev/null +++ b/tests/fixtures/golden-backups/v1.46.1.manifest.json @@ -0,0 +1,72 @@ +{ + "release": "1.46.1", + "schemaVersion": "2", + "userId": "round-trip-owner", + "exportedAt": "2026-10-29T08:54:55.373Z", + "sha256": "56d798b77d92a19a5048be1c364fdbb1b527c713a9f9dedc574b5d2c44bdc1dc", + "bytes": 41985, + "counts": { + "measurements": 5, + "medications": 1, + "intakeEvents": 1, + "medicationSideEffects": 1, + "medicationPauseEras": 2, + "medicationDoseChanges": 2, + "medicationInventoryItems": 2, + "medicationInventoryEvents": 2, + "reminderPhaseConfigs": 1, + "medicationEfficacyTargets": 1, + "medicationScheduleRevisions": 3, + "moodEntries": 1, + "customMoodTagCategories": 1, + "hiddenMoodTags": 1, + "cycles": 2, + "cycleDayLogs": 1, + "nutrientDays": 1, + "labResults": 1, + "biomarkers": 1, + "illnessEpisodes": 2, + "illnessDayLogs": 1, + "allergies": 1, + "familyHistory": 1, + "workouts": 1, + "documents": 1, + "healthProfile": 1, + "customMetrics": 1, + "healthProfileFactRevisions": 1, + "customMetricEntries": 2, + "correlationPatterns": 1, + "intradayProfiles": 1, + "healthScoreRecords": 1, + "onboardingRecords": 1, + "practitioners": 1, + "encounters": 1, + "encounterLinks": 3, + "customVaccines": 1, + "vaccinations": 1, + "vaccinationLinks": 1, + "measurementReminders": 1, + "measurementReminderEvents": 2, + "coachConversations": 2, + "coachMessages": 3, + "coachConversationDocuments": 1, + "coachFacts": 1, + "coachPlans": 1, + "coachReminders": 1, + "mentalHealthAssessments": 1, + "consentReceipts": 2, + "documentConditionLinks": 1, + "extractedFacts": 1, + "documentImportKeys": 1, + "personalRecords": 3, + "userAchievements": 2, + "environmentContexts": 2, + "environmentTravelLocations": 1, + "dawarichDays": 1, + "ecgRecordings": 1, + "symptomDefinitions": 2, + "symptomEvents": 3, + "lifeEvents": 2, + "relationLinks": 5 + } +} diff --git a/tests/integration/golden-backups.test.ts b/tests/integration/golden-backups.test.ts new file mode 100644 index 000000000..a1cee3be6 --- /dev/null +++ b/tests/integration/golden-backups.test.ts @@ -0,0 +1,271 @@ +/** + * A backup an older release wrote keeps restoring. + * + * Every other backup test exports with today's code and restores with + * today's code, so a change that moves both ends at once (a renamed field, a + * section that changes shape, a new required value) passes every one of them + * and still breaks the file a self-hoster downloaded last month. This file + * holds the other end still: each release commits the disaster-recovery file + * its own exporter wrote (`tests/fixtures/golden-backups/v.json`), + * with a manifest of what that file held, and every later release has to + * restore each committed file into an empty account and get the same counts + * back, section by section. + * + * The files go in through the admin routes an operator uses, the upload and + * then the restore (`restore-job-driver.ts` runs the queued job in-process), + * so the upload's own checks of an old file are part of the claim. The counts + * are read back through the current exporter, which is the reading a person + * gets when they export again. + * + * Ciphertext inside the files is sealed under the suite's fixed key + * (`ENCRYPTION_KEY` in `vitest.integration.config.mts`), so they open on any + * machine that runs the suite. + * + * `GOLDEN_BACKUP_WRITE=1` writes the current release's file instead of + * restoring; `tests/fixtures/golden-backups/README.md` says when and how. + */ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; + +import { + OWNER_ID, + createOwner, + seedAdminSession, + seedColumnShapes, + seedEveryTwoEndedModel, +} from "./backup-fixture"; +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const FIXTURE_DIR = resolve(__dirname, "../fixtures/golden-backups"); +const WRITE = process.env.GOLDEN_BACKUP_WRITE === "1"; +const SLOW = 240_000; + +/** What a committed file held when its release wrote it. */ +interface GoldenManifest { + release: string; + schemaVersion: string; + userId: string; + exportedAt: string; + sha256: string; + bytes: number; + counts: Record; +} + +/** + * Rows the restore is documented to drop by age rather than carry, so a file + * restored long after it was written comes back without them. Each entry is + * a count key and the reason; the count may only go down, never up. + */ +const AGES_OUT: Readonly> = { + measurements: + "a measurement tombstone older than TOMBSTONE_RETENTION_DAYS is not written back (`measurements-restore.ts`)", +}; + +function sha256(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function goldenFiles(): string[] { + return readdirSync(FIXTURE_DIR) + .filter((name) => /^v\d+\.\d+\.\d+\.json$/.test(name)) + .sort(); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe.runIf(WRITE)("golden backups: write this release's file", () => { + it( + "writes the disaster-recovery file of the seeded account", + async () => { + const prisma = getPrismaClient(); + const release = JSON.parse( + readFileSync(resolve(__dirname, "../../package.json"), "utf8"), + ).version as string; + await createOwner(prisma); + await seedEveryTwoEndedModel(prisma); + await seedColumnShapes(prisma); + // The shared seed files its illness symptom under a catalogue key of + // its own. A file restored on another instance resolves catalogue keys + // against what that instance ships, so the committed file names a + // shipped symptom instead and every link has somewhere to land. + const shipped = await prisma.illnessSymptom.findFirstOrThrow({ + where: { key: { not: "round_trip_cough" } }, + orderBy: { key: "asc" }, + }); + const own = await prisma.illnessSymptom.findUniqueOrThrow({ + where: { key: "round_trip_cough" }, + }); + await prisma.illnessSymptomLink.updateMany({ + where: { symptomId: own.id }, + data: { symptomId: shipped.id }, + }); + await prisma.illnessSymptom.delete({ where: { id: own.id } }); + + const exportedAt = new Date(); + let json = ""; + const counts = await streamFullBackupJson( + prisma, + OWNER_ID, + (chunk) => { + json += chunk; + }, + { purpose: "disaster-recovery", exportedAt }, + ); + const bytes = Buffer.from(json, "utf8"); + const payload = JSON.parse(json) as { schemaVersion: string }; + const manifest: GoldenManifest = { + release, + schemaVersion: payload.schemaVersion, + userId: OWNER_ID, + exportedAt: exportedAt.toISOString(), + sha256: sha256(bytes), + bytes: bytes.length, + counts: { ...counts }, + }; + writeFileSync(resolve(FIXTURE_DIR, `v${release}.json`), bytes); + writeFileSync( + resolve(FIXTURE_DIR, `v${release}.manifest.json`), + JSON.stringify(manifest, null, 2) + "\n", + ); + }, + SLOW, + ); +}); + +describe.skipIf(WRITE)("golden backups: every committed file restores", () => { + it("has at least one committed file, each with its manifest", () => { + const files = goldenFiles(); + expect(files.length).toBeGreaterThan(0); + for (const file of files) { + expect(readdirSync(FIXTURE_DIR)).toContain( + file.replace(/\.json$/, ".manifest.json"), + ); + } + }); + + for (const file of goldenFiles()) { + it( + `restores ${file} into an empty account with the counts it was written with`, + async () => { + const prisma = getPrismaClient(); + const bytes = readFileSync(resolve(FIXTURE_DIR, file)); + const manifest = JSON.parse( + readFileSync( + resolve(FIXTURE_DIR, file.replace(/\.json$/, ".manifest.json")), + "utf8", + ), + ) as GoldenManifest; + // The file is the release's own bytes. Regenerating it from today's + // exporter would turn this into one more same-code round trip. + expect(sha256(bytes), `${file} was rewritten`).toBe(manifest.sha256); + // A file that held nearly nothing would agree with nearly nothing. + const nonEmpty = Object.values(manifest.counts).filter((n) => n > 0); + expect(nonEmpty.length).toBeGreaterThan(40); + + await seedAdminSession(prisma); + // The empty account the file is restored into: the restore writes + // into the account the file names, which has to exist. + await prisma.user.create({ + data: { + id: manifest.userId, + username: `golden-${manifest.release}`, + email: `golden-${manifest.release}@example.test`, + }, + }); + + const { POST: upload } = + await import("@/app/api/admin/backups/upload/route"); + const uploaded = await upload( + new Request("http://localhost/api/admin/backups/upload", { + method: "POST", + headers: { "content-type": "application/json" }, + body: bytes, + }) as unknown as Parameters[0], + ); + const uploadBody = (await uploaded.json()) as { + data: { id: string } | null; + error: string | null; + }; + expect(uploaded.status, JSON.stringify(uploadBody)).toBe(201); + const backupId = uploadBody.data!.id; + + const { POST: restore } = await import("./restore-job-driver"); + const restored = await restore( + new Request( + `http://localhost/api/admin/backups/${backupId}/restore`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }, + ) as unknown as Parameters[0], + { params: Promise.resolve({ id: backupId }) }, + ); + const restoreBody = (await restored.json()) as { + data: { skipped: { links: number } } | null; + error: string | null; + meta?: unknown; + }; + expect(restored.status, JSON.stringify(restoreBody)).toBe(200); + expect( + restoreBody.data!.skipped.links, + JSON.stringify(restoreBody.data!.skipped), + ).toBe(0); + + const { counts } = await buildFullBackupPayload( + prisma, + manifest.userId, + { purpose: "disaster-recovery" }, + ); + const now: Record = { ...counts }; + for (const [section, expected] of Object.entries(manifest.counts)) { + if (section in AGES_OUT) { + expect(now[section], `${section} grew`).toBeLessThanOrEqual( + expected, + ); + expect(now[section], `${section} came back empty`).toBeGreaterThan( + 0, + ); + continue; + } + expect(now[section], `${section} after restoring ${file}`).toBe( + expected, + ); + } + }, + SLOW, + ); + } +}); From 66081e71025dacea2e519b37952ccb6789c66e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 10:03:10 +0100 Subject: [PATCH 3/3] test(backup): restore the backup of an account that was erased Nothing covered the operator's position after an erasure: a backup file, an admin session, and a server that no longer knows the account. Export the shared backup seed, delete the account through the settings route under a 14-day window and run the purge job past its end, then: - scan every text and JSON column for the account's id; only a rate-limit bucket that expires within a day may remain; - upload the file as an admin: refused as owner_not_found, nothing stored (the documented contract); - recreate the account row under its id, upload and restore: every section's count matches the export; - point the file at a freshly created account, upload and restore: every count matches and nothing names the erased account afterwards. --- .../backup-erased-account-restore.test.ts | 326 ++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 tests/integration/backup-erased-account-restore.test.ts diff --git a/tests/integration/backup-erased-account-restore.test.ts b/tests/integration/backup-erased-account-restore.test.ts new file mode 100644 index 000000000..6a507a70a --- /dev/null +++ b/tests/integration/backup-erased-account-restore.test.ts @@ -0,0 +1,326 @@ +/** + * The backup of an account that has since been erased. + * + * An account is filled (the shared backup seed), its disaster-recovery file is + * written by the exporter the weekly copy uses, and the account is then + * deleted the way a person deletes it: `DELETE /api/settings/account` under a + * 14-day grace window, and the hourly purge job run past the window's end. + * What is left is what an operator has after the fact: the file, an admin + * session, and a server that no longer knows the account. + * + * 1. The purge leaves nothing in the database that names the account. + * 2. The admin upload refuses the file, because its owner is gone, and + * stores nothing (the documented contract in `docs/ops/backup-restore.md`: + * a restore writes into an account that exists under the same id). + * 3. With the account row put back under its original id, the one way that + * contract leaves, the upload and the restore bring every section back. + * 4. Into a freshly created account, with the file's owner set to that + * account, the restore brings every section back and nothing in the + * database names the erased account afterwards. + * + * Everything runs through production code: the deletion route, the purge job, + * the admin upload and restore routes and the restore job + * (`restore-job-driver.ts` runs the queued job in-process). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; + +import { + OWNER_ID, + createOwner, + seedColumnShapes, + seedEveryTwoEndedModel, +} from "./backup-fixture"; +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: vi.fn(async () => undefined), +})); + +const DAY_MS = 86_400_000; +const SLOW = 240_000; + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.rateLimit.deleteMany(); + cookieJar.clear(); + headerJar.clear(); + vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "14"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +async function signIn(prisma: PrismaClient, userId: string) { + const session = await prisma.session.create({ + data: { userId, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +/** + * Every text and JSON cell in the public schema that contains `needle`, as + * `table.column (rows)`. Bytes are left out: ciphertext cannot be searched, + * and the backup seed seals nothing that names its owner. + */ +async function cellsNaming( + prisma: PrismaClient, + needle: string, +): Promise { + const columns = await prisma.$queryRaw< + Array<{ table_name: string; column_name: string }> + >` + SELECT c.table_name, c.column_name + FROM information_schema.columns c + JOIN information_schema.tables t + ON t.table_schema = c.table_schema AND t.table_name = c.table_name + WHERE c.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + AND c.table_name <> '_prisma_migrations' + AND (c.data_type IN ('text', 'character varying', 'json', 'jsonb') + OR c.udt_name IN ('_text', '_varchar')) + ORDER BY c.table_name, c.column_name`; + const hits: string[] = []; + for (const { table_name, column_name } of columns) { + if (table_name === "rate_limits") { + // A rate-limit bucket (`record-write:`) is counting state, not + // a record: it expires with its window and `rate-limit-cleanup` deletes + // it. Allowed only while that holds, so a bucket that outlives its + // account by more than a day still shows up. + const lasting = await prisma.$queryRaw>` + SELECT key FROM rate_limits + WHERE key LIKE '%' || ${needle} || '%' + AND reset_at > (NOW() AT TIME ZONE 'UTC') + interval '1 day'`; + hits.push(...lasting.map((row) => `rate_limits.key: ${row.key}`)); + continue; + } + // Identifiers come from the catalogue above, never from input. + const rows = await prisma.$queryRawUnsafe< + Array<{ n: bigint; sample: string | null }> + >( + `SELECT count(*) AS n, min(left("${column_name}"::text, 80)) AS sample + FROM "${table_name}" WHERE "${column_name}"::text LIKE '%' || $1 || '%'`, + needle, + ); + const n = Number(rows[0]?.n ?? 0); + if (n > 0) { + hits.push(`${table_name}.${column_name} (${n}): ${rows[0]?.sample}`); + } + } + return hits; +} + +/** The account, its file, and the account erased by the real path. */ +async function eraseAfterExport(prisma: PrismaClient) { + const admin = await prisma.user.create({ + data: { + username: "erasure-admin", + email: "erasure-admin@example.test", + role: "ADMIN", + }, + }); + await createOwner(prisma); + await seedEveryTwoEndedModel(prisma); + await seedColumnShapes(prisma); + // The seed's illness symptom lives in a catalogue row of its own, which + // the purge rightly keeps (catalogues are the instance's). Re-point the + // link at a shipped symptom so the scan below reads the account alone. + const shipped = await prisma.illnessSymptom.findFirstOrThrow({ + where: { key: { not: "round_trip_cough" } }, + orderBy: { key: "asc" }, + }); + const own = await prisma.illnessSymptom.findUniqueOrThrow({ + where: { key: "round_trip_cough" }, + }); + await prisma.illnessSymptomLink.updateMany({ + where: { symptomId: own.id }, + data: { symptomId: shipped.id }, + }); + await prisma.illnessSymptom.delete({ where: { id: own.id } }); + + let json = ""; + const exported = await streamFullBackupJson( + prisma, + OWNER_ID, + (chunk) => { + json += chunk; + }, + { purpose: "disaster-recovery" }, + ); + const counts: Record = { ...exported }; + + // The person deletes their account; the window starts. + await signIn(prisma, OWNER_ID); + const { DELETE } = await import("@/app/api/settings/account/route"); + const { NextRequest } = await import("next/server"); + const deleted = await DELETE( + new NextRequest("http://localhost/api/settings/account", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), + }), + ); + expect(deleted.status, await deleted.clone().text()).toBe(200); + expect( + (await prisma.user.findUniqueOrThrow({ where: { id: OWNER_ID } })) + .deletionPurgeAt, + ).not.toBeNull(); + + // The window ends and the hourly job runs to completion. + const { processDueAccountDeletions } = + await import("@/lib/jobs/account-deletion-purge"); + const report = await processDueAccountDeletions( + new Date(Date.now() + 15 * DAY_MS), + ); + expect(report).toMatchObject({ due: 1, purged: 1, refused: 0, failed: 0 }); + expect(await prisma.user.findUnique({ where: { id: OWNER_ID } })).toBeNull(); + + cookieJar.clear(); + await signIn(prisma, admin.id); + return { admin, json, counts }; +} + +async function upload(body: string) { + const { POST } = await import("@/app/api/admin/backups/upload/route"); + const res = await POST( + new Request("http://localhost/api/admin/backups/upload", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }) as unknown as Parameters[0], + ); + return { + status: res.status, + body: (await res.json()) as { + data: { id: string } | null; + error: string | null; + meta?: { reason?: string; errorCode?: string }; + }, + }; +} + +async function restore(backupId: string) { + const { POST } = await import("./restore-job-driver"); + const res = await POST( + new Request(`http://localhost/api/admin/backups/${backupId}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as unknown as Parameters[0], + { params: Promise.resolve({ id: backupId }) }, + ); + return { + status: res.status, + body: (await res.json()) as { + data: { skipped: { links: number } } | null; + error: string | null; + }, + }; +} + +async function countsOf(prisma: PrismaClient, userId: string) { + const { counts } = await buildFullBackupPayload(prisma, userId, { + purpose: "disaster-recovery", + }); + return { ...counts } as Record; +} + +describe("restoring the backup of an erased account", () => { + it( + "the purge leaves nothing that names the account, and the upload refuses its file", + async () => { + const prisma = getPrismaClient(); + const { json, counts } = await eraseAfterExport(prisma); + expect(Object.values(counts).filter((n) => n > 0).length).toBeGreaterThan( + 40, + ); + + expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]); + + const refused = await upload(json); + expect(refused.status, JSON.stringify(refused.body)).toBe(422); + const denial = await prisma.auditLog.findFirstOrThrow({ + where: { action: "admin.backups.upload.denied" }, + }); + expect(JSON.parse(denial.details ?? "{}")).toMatchObject({ + reason: "owner_not_found", + ownerId: OWNER_ID, + }); + expect(await prisma.dataBackup.count()).toBe(0); + }, + SLOW, + ); + + it( + "comes back whole once the account row exists again under its id", + async () => { + const prisma = getPrismaClient(); + const { json, counts } = await eraseAfterExport(prisma); + await createOwner(prisma); + + const stored = await upload(json); + expect(stored.status, JSON.stringify(stored.body)).toBe(201); + const restored = await restore(stored.body.data!.id); + expect(restored.status, JSON.stringify(restored.body)).toBe(200); + expect(restored.body.data!.skipped.links).toBe(0); + expect(await countsOf(prisma, OWNER_ID)).toEqual(counts); + }, + SLOW, + ); + + it( + "comes back whole into a freshly created account, and nothing names the erased one", + async () => { + const prisma = getPrismaClient(); + const { json, counts } = await eraseAfterExport(prisma); + const fresh = await prisma.user.create({ + data: { + username: "returning-person", + email: "returning-person@example.test", + }, + }); + // The file names its owner once, at the top; the restore targets the + // account it names. Point it at the new account. + const payload = JSON.parse(json) as { userId: string }; + expect(payload.userId).toBe(OWNER_ID); + payload.userId = fresh.id; + + const stored = await upload(JSON.stringify(payload)); + expect(stored.status, JSON.stringify(stored.body)).toBe(201); + const restored = await restore(stored.body.data!.id); + expect(restored.status, JSON.stringify(restored.body)).toBe(200); + expect(restored.body.data!.skipped.links).toBe(0); + + expect(await countsOf(prisma, fresh.id)).toEqual(counts); + expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]); + }, + SLOW, + ); +});