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/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 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/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, + ); +}); 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, + ); + } +});