From b179e34f581442306ecd9ab640caea02279f784f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:30:40 +0200 Subject: [PATCH] test(integration): round-trip every account setting through the API and a backup One table holds an entry per write route, and every User column the backup classifies as a setting appears in exactly one entry; a new setting column fails the inventory case until it has one. For each entry the file sets a non-default value through its write route, reads it back through its read route and through /api/auth/me where that publishes it, and checks the stored column moved off what a fresh account holds. The backup leg runs through the routes too: the portable full backup a person downloads, the operator's upload, and the restore into an account recreated under the same id. Every setting must read back identically on the restored account, through the same routes and the account payload, and every stored column must come back with its value. --- tests/integration/settings-round-trip.test.ts | 1196 +++++++++++++++++ 1 file changed, 1196 insertions(+) create mode 100644 tests/integration/settings-round-trip.test.ts diff --git a/tests/integration/settings-round-trip.test.ts b/tests/integration/settings-round-trip.test.ts new file mode 100644 index 000000000..d89a6a4e2 --- /dev/null +++ b/tests/integration/settings-round-trip.test.ts @@ -0,0 +1,1196 @@ +/** + * Every setting a person can choose survives the whole trip: set through the + * API, read back through the API and `/api/auth/me`, carried out in a backup, + * restored into a fresh account, and read back there. + * + * The table below has one entry per write, and every `User` column the backup + * classifies as a `SETTING` (`USER_COLUMN_BACKUP_CLASS` in + * `src/lib/export/backup-plan.ts`) appears in exactly one entry. A new setting + * column fails the inventory case until it is given an entry, which is the + * point: the class of bug this file exists for is a setting that is stored but + * not read back, read back but not published, or published but dropped by the + * backup, and each of those used to be found by somebody restoring an account. + * + * Every request runs through the real API kit against the real database with + * the kit's contract check, so each write and read is also checked against + * the OpenAPI registry the native client is generated from. + * + * The backup leg goes through the routes too: the portable full backup a + * person downloads, the operator's upload, and the operator's restore into an + * account recreated under the same id, the way an account is brought back on + * a new host. Only the restore's queue hop is replaced (see + * `restore-job-driver.ts`). + */ +import { isDeepStrictEqual } from "node:util"; + +import { beforeAll, describe, expect, it } from "vitest"; + +import type { User } from "@/generated/prisma/client"; +import { + USER_COLUMN_BACKUP_CLASS, + type AccountSettingColumn, +} from "@/lib/export/backup-plan"; + +import { GET as getMe } from "@/app/api/auth/me/route"; +import * as profile from "@/app/api/user/profile/route"; +import * as diabetes from "@/app/api/auth/me/diabetes/route"; +import * as autoAiRead from "@/app/api/auth/me/documents-auto-ai-read/route"; +import * as labsOcr from "@/app/api/auth/me/labs-local-ocr/route"; +import * as workoutRoutes from "@/app/api/auth/me/workout-routes/route"; +import * as centralCodex from "@/app/api/auth/me/use-central-codex/route"; +import * as disableCoach from "@/app/api/auth/me/disable-coach/route"; +import * as unitPreference from "@/app/api/auth/me/unit-preference/route"; +import * as glucoseUnit from "@/app/api/auth/me/glucose-unit/route"; +import * as injectionSites from "@/app/api/auth/me/injection-site-prefs/route"; +import * as modules from "@/app/api/auth/me/modules/route"; +import * as healthScore from "@/app/api/auth/me/health-score-config/route"; +import * as sourcePriority from "@/app/api/auth/me/source-priority/route"; +import * as notificationPrefs from "@/app/api/auth/me/notification-prefs/route"; +import * as reportSelection from "@/app/api/auth/me/report-selection/route"; +import * as coachPrefs from "@/app/api/auth/me/coach-prefs/route"; +import { PUT as putAiUsage } from "@/app/api/auth/me/ai-usage/route"; +import { POST as dismissPasskeyNudge } from "@/app/api/auth/me/passkey-nudge/dismiss/route"; +import { GET as getMfa } from "@/app/api/auth/me/mfa/route"; +import { POST as uploadAvatar } from "@/app/api/user/avatar/route"; +import { GET as getAvatar } from "@/app/api/user/avatar/[id]/route"; +import * as aiProvider from "@/app/api/user/ai-provider/route"; +import * as providerChain from "@/app/api/insights/provider-chain/route"; +import * as insightsSettings from "@/app/api/insights/settings/route"; +import { POST as completeOnboarding } from "@/app/api/onboarding/complete/route"; +import { POST as onboardingTour } from "@/app/api/onboarding/tour/route"; +import { POST as onboardingDisclaimer } from "@/app/api/onboarding/disclaimer/route"; +import { GET as getEnvironment } from "@/app/api/environment/route"; +import { PUT as putHome } from "@/app/api/environment/home/route"; +import { PATCH as patchEnvironmentPrefs } from "@/app/api/environment/preferences/route"; +import * as thresholds from "@/app/api/user/thresholds/route"; +import * as dashboardWidgets from "@/app/api/dashboard/widgets/route"; +import * as insightsLayout from "@/app/api/insights/layout/route"; +import * as medicationLayout from "@/app/api/medications/layout/route"; +import * as moodTagLayout from "@/app/api/mood/tags/layout/route"; +import * as documentsLayout from "@/app/api/documents/inbound/layout/route"; +import * as moduleListLayout from "@/app/api/settings/module-list-layout/route"; +import * as previewFeatures from "@/app/api/settings/preview-features/route"; +import * as activityGoals from "@/app/api/activity/goals/route"; +import * as healthKit from "@/app/api/integrations/healthkit/route"; +import { POST as exportHealthRecord } from "@/app/api/export/health-record/route"; +import { GET as exportFullBackup } from "@/app/api/export/full-backup/route"; +import { POST as uploadBackup } from "@/app/api/admin/backups/upload/route"; + +import { + callRoute, + type CallRouteOptions, + type HttpMethod, + type RouteResult, +} from "../kit/call-route"; +import { + makeAdmin, + makeSession, + makeUser, + type SessionFixture, +} from "../kit/factories"; +import { POST as restoreBackup } from "./restore-job-driver"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); + +type Handler = Parameters[0]; + +interface Call { + handler: Handler; + method: HttpMethod; + route: string; + params?: Record; + body?: unknown; + contract?: CallRouteOptions["contract"]; +} + +interface Read { + call: Call; + /** What a client reads off the answer. */ + pick: (res: RouteResult) => unknown; +} + +interface Entry { + /** The `User` columns this write sets. */ + columns: readonly AccountSettingColumn[]; + write: Call; + /** + * How the value is read back. Every entry has one, so a setting that can + * be written but not read is a missing route, not a missing check. + */ + read: Read; + /** The value `read.pick` must return after the write. */ + expected?: unknown; + /** Where `/api/auth/me` publishes it, when it does. */ + me?: { pick: (me: unknown) => unknown; expected?: unknown }; +} + +/** `data.a.b[0].c` of a response, by a dotted path. */ +function at(value: unknown, path: string): unknown { + let current: unknown = value; + for (const key of path.split(".")) { + if (current === null || current === undefined) return undefined; + if (Array.isArray(current) && /^\d+$/.test(key)) { + current = current[Number(key)]; + } else if (typeof current === "object") { + current = (current as Record)[key]; + } else { + return undefined; + } + } + return current; +} + +const data = + (path: string) => + (res: RouteResult): unknown => + at(res.data, path); + +const get = (handler: Handler, route: string, path: string): Read => ({ + call: { handler, method: "GET", route }, + pick: data(path), +}); + +/** A 1×1 PNG: the smallest image the avatar route's sniffing accepts. */ +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", +); + +function avatarForm(): FormData { + const form = new FormData(); + form.append("file", new File([PNG], "avatar.png", { type: "image/png" })); + return form; +} + +/** + * In the order they run. Onboarding first, because a fresh account starts + * without it; the doctor report before the report selection, because the + * export also stores the selection it was given. + */ +function entries(user: Pick): Entry[] { + return [ + { + columns: ["onboardingCompletedAt"], + write: { + handler: completeOnboarding, + method: "POST", + route: "/api/onboarding/complete", + body: {}, + }, + read: { + call: { handler: getMe, method: "GET", route: "/api/auth/me" }, + pick: data("onboardingCompletedAt"), + }, + me: { pick: (me) => typeof at(me, "onboardingCompletedAt") }, + }, + { + columns: ["onboardingTourCompleted", "onboardingTourProgressJson"], + write: { + handler: onboardingTour, + method: "POST", + route: "/api/onboarding/tour", + body: { + completed: true, + outcome: "completed", + progress: { + lastStopId: "mood", + completedStopIds: ["dashboard"], + status: "in_progress", + updatedAt: "2026-10-22T10:00:00.000Z", + }, + }, + }, + read: get(getMe, "/api/auth/me", "onboardingTourProgress.lastStopId"), + expected: "mood", + me: { pick: (me) => at(me, "onboardingTourCompleted"), expected: true }, + }, + { + columns: ["disclaimerAcknowledgedAt", "disclaimerAcknowledgedVersion"], + write: { + handler: onboardingDisclaimer, + method: "POST", + route: "/api/onboarding/disclaimer", + body: { version: "2026-06-18" }, + }, + read: get(getMe, "/api/auth/me", "disclaimerAcknowledgedVersion"), + expected: "2026-06-18", + me: { + pick: (me) => typeof at(me, "disclaimerAcknowledgedAt"), + expected: "string", + }, + }, + { + columns: [ + "heightCm", + "dateOfBirth", + "gender", + "displayName", + "fullName", + "insurerName", + "insuranceNumberEncrypted", + "insurerIkNumber", + "locale", + "timezone", + "timeFormat", + "dateFormat", + "moodReminderEnabled", + ], + write: { + handler: profile.PATCH, + method: "PATCH", + route: "/api/user/profile", + body: { + heightCm: 182, + dateOfBirth: "1985-04-12", + gender: "FEMALE", + displayName: "Kit Person", + fullName: "Kit Example Person", + insurerName: "Example Insurer", + insuranceNumber: "A123456780", + insurerIkNumber: "123456789", + locale: "en", + timezone: "America/New_York", + timeFormat: "H12", + dateFormat: "YMD", + moodReminderEnabled: true, + }, + }, + read: { + call: { + handler: profile.GET, + method: "GET", + route: "/api/user/profile", + }, + pick: (res) => { + const d = res.data as Record; + return { + heightCm: d.heightCm, + dateOfBirth: String(d.dateOfBirth).slice(0, 10), + gender: d.gender, + displayName: d.displayName, + fullName: d.fullName, + insurerName: d.insurerName, + insuranceNumber: d.insuranceNumber, + insurerIkNumber: d.insurerIkNumber, + locale: d.locale, + timezone: d.timezone, + timeFormat: d.timeFormat, + dateFormat: d.dateFormat, + moodReminderEnabled: d.moodReminderEnabled, + }; + }, + }, + expected: { + heightCm: 182, + dateOfBirth: "1985-04-12", + gender: "FEMALE", + displayName: "Kit Person", + fullName: "Kit Example Person", + insurerName: "Example Insurer", + insuranceNumber: "A123456780", + insurerIkNumber: "123456789", + locale: "en", + timezone: "America/New_York", + timeFormat: "H12", + dateFormat: "YMD", + moodReminderEnabled: true, + }, + me: { + pick: (me) => ({ + heightCm: at(me, "heightCm"), + gender: at(me, "gender"), + displayName: at(me, "displayName"), + insuranceNumber: at(me, "insuranceNumber"), + timezone: at(me, "timezone"), + timeFormat: at(me, "timeFormat"), + dateFormat: at(me, "dateFormat"), + }), + expected: { + heightCm: 182, + gender: "FEMALE", + displayName: "Kit Person", + insuranceNumber: "A123456780", + timezone: "America/New_York", + timeFormat: "H12", + dateFormat: "YMD", + }, + }, + }, + { + columns: ["hasDiabetes"], + write: { + handler: diabetes.PATCH, + method: "PATCH", + route: "/api/auth/me/diabetes", + body: { hasDiabetes: true }, + }, + read: get(diabetes.GET, "/api/auth/me/diabetes", "hasDiabetes"), + expected: true, + }, + { + columns: ["documentsAutoAiRead"], + write: { + handler: autoAiRead.PATCH, + method: "PATCH", + route: "/api/auth/me/documents-auto-ai-read", + body: { documentsAutoAiRead: true }, + }, + read: get( + autoAiRead.GET, + "/api/auth/me/documents-auto-ai-read", + "documentsAutoAiRead", + ), + expected: true, + }, + { + columns: ["labsLocalOcrEnabled"], + write: { + handler: labsOcr.PATCH, + method: "PATCH", + route: "/api/auth/me/labs-local-ocr", + body: { labsLocalOcrEnabled: true }, + }, + read: get( + labsOcr.GET, + "/api/auth/me/labs-local-ocr", + "labsLocalOcrEnabled", + ), + expected: true, + }, + { + columns: ["storeWorkoutRoutes"], + write: { + handler: workoutRoutes.PATCH, + method: "PATCH", + route: "/api/auth/me/workout-routes", + body: { storeWorkoutRoutes: true }, + }, + read: get( + workoutRoutes.GET, + "/api/auth/me/workout-routes", + "storeWorkoutRoutes", + ), + expected: true, + }, + { + columns: ["useCentralCodex"], + write: { + handler: centralCodex.PATCH, + method: "PATCH", + route: "/api/auth/me/use-central-codex", + body: { useCentralCodex: true }, + }, + read: get( + centralCodex.GET, + "/api/auth/me/use-central-codex", + "useCentralCodex", + ), + expected: true, + }, + { + columns: ["disableCoach"], + write: { + handler: disableCoach.PATCH, + method: "PATCH", + route: "/api/auth/me/disable-coach", + body: { disableCoach: true }, + }, + read: get(disableCoach.GET, "/api/auth/me/disable-coach", "disableCoach"), + expected: true, + me: { pick: (me) => at(me, "disableCoach"), expected: true }, + }, + { + columns: ["unitPreference"], + write: { + handler: unitPreference.PATCH, + method: "PATCH", + route: "/api/auth/me/unit-preference", + body: { unitPreference: "imperial" }, + }, + read: get( + unitPreference.GET, + "/api/auth/me/unit-preference", + "unitPreference", + ), + expected: "imperial", + me: { pick: (me) => at(me, "unitPreference"), expected: "imperial" }, + }, + { + columns: ["glucoseUnit"], + write: { + handler: glucoseUnit.PATCH, + method: "PATCH", + route: "/api/auth/me/glucose-unit", + body: { glucoseUnit: "mmol/L" }, + }, + read: get(glucoseUnit.GET, "/api/auth/me/glucose-unit", "glucoseUnit"), + expected: "mmol/L", + me: { pick: (me) => at(me, "glucoseUnit"), expected: "mmol/L" }, + }, + { + columns: ["globalExcludedInjectionSites"], + write: { + handler: injectionSites.PATCH, + method: "PATCH", + route: "/api/auth/me/injection-site-prefs", + body: { + globalExcludedInjectionSites: ["THIGH_LEFT", "UPPER_ARM_RIGHT"], + }, + }, + read: get( + injectionSites.GET, + "/api/auth/me/injection-site-prefs", + "globalExcludedInjectionSites", + ), + }, + { + columns: ["modulePreferencesJson"], + write: { + handler: modules.PATCH, + method: "PATCH", + route: "/api/auth/me/modules", + body: { mood: false }, + }, + read: get(modules.GET, "/api/auth/me/modules", "modules.mood"), + expected: false, + me: { pick: (me) => at(me, "modules.mood"), expected: false }, + }, + { + columns: ["healthScoreConfigJson"], + write: { + handler: healthScore.PATCH, + method: "PATCH", + route: "/api/auth/me/health-score-config", + body: { + pillars: [ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + ], + }, + }, + read: get( + healthScore.GET, + "/api/auth/me/health-score-config", + "excludedPillars", + ), + expected: ["LIPIDS"], + }, + { + columns: ["sourcePriorityJson"], + write: { + handler: sourcePriority.PUT, + method: "PUT", + route: "/api/auth/me/source-priority", + body: { metricPriority: { steps: ["MANUAL"] } }, + }, + read: get( + sourcePriority.GET, + "/api/auth/me/source-priority", + "metricPriority.steps.0", + ), + expected: "MANUAL", + }, + { + columns: ["notificationPrefs"], + write: { + handler: notificationPrefs.PATCH, + method: "PATCH", + route: "/api/auth/me/notification-prefs", + body: { mood: { reminderHour: 7 } }, + }, + read: get( + notificationPrefs.GET, + "/api/auth/me/notification-prefs", + "mood.reminderHour", + ), + expected: 7, + me: { + pick: (me) => at(me, "notificationPrefs.mood.reminderHour"), + expected: 7, + }, + }, + { + columns: ["lastReportPracticeName"], + write: { + handler: exportHealthRecord, + method: "POST", + route: "/api/export/health-record", + body: { + format: "fhir", + selection: { v: 2, leaves: ["PATIENT_IDENTITY"] }, + practiceName: "Example Practice", + }, + }, + read: get(getMe, "/api/auth/me", "lastReportPracticeName"), + expected: "Example Practice", + }, + { + columns: ["reportSelectionJson"], + write: { + handler: reportSelection.PUT, + method: "PUT", + route: "/api/auth/me/report-selection", + body: { + v: 2, + leaves: ["PATIENT_IDENTITY", "MEDICATION_LIST"], + format: "fhir", + rangeDays: 30, + includeCharts: false, + }, + }, + read: get( + reportSelection.GET, + "/api/auth/me/report-selection", + "profile.leaves", + ), + expected: ["PATIENT_IDENTITY", "MEDICATION_LIST"], + me: { pick: (me) => at(me, "reportSelection.leaves") }, + }, + { + columns: ["coachPrefsJson", "insightsExcludeMetrics"], + write: { + handler: coachPrefs.PUT, + method: "PUT", + route: "/api/auth/me/coach-prefs", + body: { + tone: "concise", + verbosity: "brief", + excludeMetrics: ["weight"], + reasoning: "low", + }, + }, + read: { + call: { + handler: coachPrefs.GET, + method: "GET", + route: "/api/auth/me/coach-prefs", + }, + pick: (res) => ({ + tone: at(res.data, "tone"), + verbosity: at(res.data, "verbosity"), + excludeMetrics: at(res.data, "excludeMetrics"), + }), + }, + expected: { + tone: "concise", + verbosity: "brief", + excludeMetrics: ["weight"], + }, + me: { + pick: (me) => at(me, "coachReasoning.preference"), + expected: "low", + }, + }, + { + columns: ["aiUsageLevel"], + write: { + handler: putAiUsage, + method: "PUT", + route: "/api/auth/me/ai-usage", + body: { level: "frugal" }, + }, + // The route has no GET; the account payload is where a client reads it. + read: get(getMe, "/api/auth/me", "ai.usage.preference"), + expected: "frugal", + }, + { + columns: ["passkeyUpgradeNudgeDismissed"], + write: { + handler: dismissPasskeyNudge, + method: "POST", + route: "/api/auth/me/passkey-nudge/dismiss", + }, + read: get(getMfa, "/api/auth/me/mfa", "passkeyNudgeDismissed"), + expected: true, + }, + { + columns: ["avatarBytes", "avatarContentType", "avatarUpdatedAt"], + write: { + handler: uploadAvatar, + method: "POST", + route: "/api/user/avatar", + body: avatarForm(), + }, + read: { + call: { + handler: getAvatar, + method: "GET", + route: "/api/user/avatar/{id}", + params: { id: user.id }, + }, + pick: (res) => ({ + contentType: res.headers.get("content-type"), + bytes: typeof res.body === "string" ? res.body.length : null, + }), + }, + me: { pick: (me) => at(me, "avatarUrl") }, + }, + { + columns: [ + "aiProvider", + "aiModel", + "aiBaseUrl", + "aiCompatBaseUrl", + "aiCompatModel", + "aiResponseTimeoutSeconds", + ], + write: { + handler: aiProvider.PATCH, + method: "PATCH", + route: "/api/user/ai-provider", + body: { + provider: "LOCAL", + model: "llama3.1:8b", + baseUrl: "https://ollama.example.com", + compatBaseUrl: "https://gateway.example.com/v1", + compatModel: "gpt-4o-mini", + responseTimeoutSeconds: 120, + }, + }, + read: { + call: { + handler: aiProvider.GET, + method: "GET", + route: "/api/user/ai-provider", + }, + pick: (res) => ({ + provider: at(res.data, "provider"), + model: at(res.data, "model"), + baseUrl: at(res.data, "baseUrl"), + compatBaseUrl: at(res.data, "compatBaseUrl"), + compatModel: at(res.data, "compatModel"), + responseTimeoutSeconds: at(res.data, "responseTimeoutSeconds"), + }), + }, + expected: { + provider: "LOCAL", + model: "llama3.1:8b", + baseUrl: "https://ollama.example.com", + compatBaseUrl: "https://gateway.example.com/v1", + compatModel: "gpt-4o-mini", + responseTimeoutSeconds: 120, + }, + me: { + pick: (me) => at(me, "ai.provider.responseTimeoutMs"), + expected: 120_000, + }, + }, + { + columns: ["aiProviderChain"], + write: { + handler: providerChain.PUT, + method: "PUT", + route: "/api/insights/provider-chain", + body: { + chain: [ + { providerType: "anthropic", enabled: true }, + { providerType: "openai", enabled: false }, + ], + }, + }, + read: { + call: { + handler: providerChain.GET, + method: "GET", + route: "/api/insights/provider-chain", + }, + pick: (res) => + ( + at(res.data, "configuredChain") as + Array> | undefined + )?.map((entry) => [entry.providerType, entry.enabled]), + }, + expected: [ + ["anthropic", true], + ["openai", false], + ], + }, + { + columns: ["insightsPrivacyMode"], + write: { + handler: insightsSettings.PUT, + method: "PUT", + route: "/api/insights/settings", + body: { privacyMode: "raw" }, + }, + read: get(insightsSettings.GET, "/api/insights/settings", "privacyMode"), + expected: "raw", + }, + { + columns: ["homeLocationEncrypted", "homeTimezone", "homeSince"], + write: { + handler: putHome, + method: "PUT", + route: "/api/environment/home", + body: { + lat: 48.1, + lon: 11.6, + label: "Home", + timezone: "Europe/Berlin", + }, + }, + read: { + call: { + handler: getEnvironment, + method: "GET", + route: "/api/environment", + }, + pick: (res) => ({ + lat: at(res.data, "home.lat"), + lon: at(res.data, "home.lon"), + label: at(res.data, "home.label"), + timezone: at(res.data, "home.timezone"), + since: typeof at(res.data, "home.since"), + }), + }, + expected: { + lat: 48.1, + lon: 11.6, + label: "Home", + timezone: "Europe/Berlin", + since: "string", + }, + }, + { + columns: ["environmentAirQualityEnabled"], + write: { + handler: patchEnvironmentPrefs, + method: "PATCH", + route: "/api/environment/preferences", + body: { airQualityEnabled: false }, + }, + read: get(getEnvironment, "/api/environment", "airQuality.enabled"), + expected: false, + }, + { + columns: ["thresholdsJson"], + write: { + handler: thresholds.PUT, + method: "PUT", + route: "/api/user/thresholds", + body: { PULSE: { min: 55, max: 95 } }, + }, + read: get(thresholds.GET, "/api/user/thresholds", "overrides.PULSE"), + expected: { min: 55, max: 95 }, + }, + { + columns: ["dashboardWidgetsJson"], + write: { + handler: dashboardWidgets.PUT, + method: "PUT", + route: "/api/dashboard/widgets", + body: { version: 1, todayCardVisible: false }, + }, + read: get( + dashboardWidgets.GET, + "/api/dashboard/widgets", + "todayCardVisible", + ), + expected: false, + }, + { + columns: ["insightsLayoutJson"], + write: { + handler: insightsLayout.PUT, + method: "PUT", + route: "/api/insights/layout", + body: { + version: 2, + sections: [{ id: "vitals", visible: false, order: 0 }], + }, + }, + read: { + call: { + handler: insightsLayout.GET, + method: "GET", + route: "/api/insights/layout", + }, + pick: (res) => + ( + at(res.data, "sections") as + Array> | undefined + )?.find((s) => s.id === "vitals")?.visible, + }, + expected: false, + }, + { + columns: ["medicationListLayoutJson"], + write: { + handler: medicationLayout.PUT, + method: "PUT", + route: "/api/medications/layout", + body: { version: 1, view: "table" }, + }, + read: get(medicationLayout.GET, "/api/medications/layout", "view"), + expected: "table", + }, + { + columns: ["moodTagLayoutJson"], + write: { + handler: moodTagLayout.PUT, + method: "PUT", + route: "/api/mood/tags/layout", + body: { placements: { emotions: ["happy", "calm"] } }, + }, + read: get( + moodTagLayout.GET, + "/api/mood/tags/layout", + "placements.emotions", + ), + expected: ["happy", "calm"], + }, + { + columns: ["documentsLayoutJson"], + write: { + handler: documentsLayout.PUT, + method: "PUT", + route: "/api/documents/inbound/layout", + body: { version: 1, view: "list", arrangement: "stacked" }, + }, + read: { + call: { + handler: documentsLayout.GET, + method: "GET", + route: "/api/documents/inbound/layout", + }, + pick: (res) => [at(res.data, "view"), at(res.data, "arrangement")], + }, + expected: ["list", "stacked"], + }, + { + columns: ["moduleListLayoutJson"], + write: { + handler: moduleListLayout.PUT, + method: "PUT", + route: "/api/settings/module-list-layout", + body: { + version: 1, + list: "illness", + view: "list", + sortDir: "alphaAsc", + }, + }, + read: { + call: { + handler: moduleListLayout.GET, + method: "GET", + route: "/api/settings/module-list-layout", + }, + pick: (res) => [ + at(res.data, "lists.illness.view"), + at(res.data, "lists.illness.sortDir"), + ], + }, + expected: ["list", "alphaAsc"], + }, + { + columns: ["previewFeaturesJson"], + write: { + handler: previewFeatures.PATCH, + method: "PATCH", + route: "/api/settings/preview-features", + body: { topics: true }, + }, + read: get( + previewFeatures.GET, + "/api/settings/preview-features", + "topics", + ), + expected: true, + }, + { + columns: ["activityGoalsJson"], + write: { + handler: activityGoals.PATCH, + method: "PATCH", + route: "/api/activity/goals", + body: { workoutsPerWeek: 4, stepsPerWeek: 70_000 }, + }, + read: { + call: { + handler: activityGoals.GET, + method: "GET", + route: "/api/activity/goals", + }, + pick: (res) => [ + at(res.data, "workoutsPerWeek"), + at(res.data, "stepsPerWeek"), + ], + }, + expected: [4, 70_000], + }, + { + columns: ["healthKitConfigJson"], + write: { + handler: healthKit.PATCH, + method: "PATCH", + route: "/api/integrations/healthkit", + body: { entries: [{ id: "heartRate", direction: "disabled" }] }, + }, + read: { + call: { + handler: healthKit.GET, + method: "GET", + route: "/api/integrations/healthkit", + }, + pick: (res) => + ( + at(res.data, "entries") as + Array> | undefined + )?.find((e) => e.id === "heartRate")?.direction, + }, + expected: "disabled", + }, + ]; +} + +const SETTING_COLUMNS = Object.entries(USER_COLUMN_BACKUP_CLASS) + .filter(([, verdict]) => verdict === "SETTING") + .map(([column]) => column) + .sort(); + +/** The setting columns whose stored form is ciphertext, compared through the API. */ +const SEALED = new Set([ + "insuranceNumberEncrypted", + "homeLocationEncrypted", +]); + +function columnsOf(user: User): Map { + return new Map(Object.entries(user)); +} + +/** + * A stored value in a form two rows can be compared in. In a JSON column a + * key holding `null` and a key left out mean the same unset field (the + * restore writes the second where the settings route wrote the first), and + * key order carries nothing, so both are normalised away. + */ +function comparable(value: unknown): string { + if (value instanceof Uint8Array) + return `bytes:${Buffer.from(value).toString("base64")}`; + if (value instanceof Date) return `date:${value.toISOString()}`; + return JSON.stringify(normalise(value ?? null)); +} + +function normalise(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalise); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([, v]) => v !== null && v !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => [k, normalise(v)]), + ); + } + return value; +} + +async function call( + entryCall: Call, + auth: SessionFixture, +): Promise { + return callRoute(entryCall.handler, { + method: entryCall.method, + route: entryCall.route, + params: entryCall.params, + body: entryCall.body, + contract: entryCall.contract, + auth, + }); +} + +describe("every setting a person can choose", () => { + it("has exactly one entry in the round-trip table", () => { + const listed = entries({ id: "inventory" }).flatMap((e) => e.columns); + expect(SETTING_COLUMNS.length).toBeGreaterThanOrEqual(60); + expect([...listed].sort()).toEqual(SETTING_COLUMNS); + expect(new Set(listed).size).toBe(listed.length); + }); + + describe("set, read back, backed up, restored, read back again", () => { + let owner: User; + let session: SessionFixture; + const before = new Map(); + const meBefore = new Map(); + let stored: Map; + let defaults: Map; + + beforeAll(async () => { + await truncateAllTables(prisma); + owner = await makeUser({ onboardingCompletedAt: null }); + session = await makeSession(owner); + defaults = columnsOf(await makeUser({ onboardingCompletedAt: null })); + }); + + it("stores each setting through its write route and answers it on its read route", async () => { + const failures: string[] = []; + for (const [index, entry] of entries(owner).entries()) { + const label = entry.columns.join(", "); + let written: RouteResult; + let read: RouteResult; + try { + written = await call(entry.write, session); + if (written.status >= 300) { + failures.push( + `${label}: write answered ${written.status} ${JSON.stringify(written.body).slice(0, 300)}`, + ); + continue; + } + read = await call(entry.read.call, session); + } catch (error) { + failures.push(`${label}: ${String(error).slice(0, 400)}`); + continue; + } + if (read.status !== 200) { + failures.push(`${label}: read answered ${read.status}`); + continue; + } + const value = entry.read.pick(read); + before.set(index, value); + if ("expected" in entry && !isDeepStrictEqual(value, entry.expected)) { + failures.push( + `${label}: read back ${JSON.stringify(value)}, wrote ${JSON.stringify(entry.expected)}`, + ); + } + } + expect(failures).toEqual([]); + }); + + it("publishes the settings the account payload carries", async () => { + const me = await callRoute(getMe, { + method: "GET", + route: "/api/auth/me", + auth: session, + }); + expect(me.status).toBe(200); + const failures: string[] = []; + for (const [index, entry] of entries(owner).entries()) { + if (!entry.me) continue; + const value = entry.me.pick(me.data); + meBefore.set(index, value); + if ( + "expected" in entry.me && + !isDeepStrictEqual(value, entry.me.expected) + ) { + failures.push( + `${entry.columns.join(", ")}: /api/auth/me has ${JSON.stringify(value)}`, + ); + } + } + expect(failures).toEqual([]); + }); + + it("moves every setting column off its default", async () => { + stored = columnsOf( + await prisma.user.findUniqueOrThrow({ where: { id: owner.id } }), + ); + const untouched = SETTING_COLUMNS.filter( + (column) => + comparable(stored.get(column)) === comparable(defaults.get(column)), + ); + expect( + untouched, + "these columns still hold what a fresh account holds", + ).toEqual([]); + }); + + it("comes back from a backup restored into a recreated account", async () => { + const exported = await callRoute(exportFullBackup, { + method: "GET", + route: "/api/export/full-backup", + auth: session, + }); + expect(exported.status).toBe(200); + + // The account is recreated under the same id, the way an operator + // brings an account back on a new host, and nothing else of it remains. + await prisma.user.delete({ where: { id: owner.id } }); + const fresh = await makeUser({ + id: owner.id, + username: owner.username, + email: owner.email, + onboardingCompletedAt: null, + }); + const admin = await makeAdmin(); + const operator = await makeSession(admin); + + const uploaded = await callRoute<{ id: string }>(uploadBackup, { + method: "POST", + route: "/api/admin/backups/upload", + body: exported.body, + auth: operator, + contract: { + skip: "the operator's backup routes are not in the published contract", + }, + }); + expect( + uploaded.status, + JSON.stringify(uploaded.body).slice(0, 400), + ).toBeLessThan(300); + const backupId = uploaded.data.id; + + const restored = await callRoute(restoreBackup, { + method: "POST", + route: "/api/admin/backups/{id}/restore", + params: { id: backupId }, + body: { confirm: "RESTORE" }, + auth: operator, + contract: { + skip: "the restore driver answers 200 with the job's report in place of the route's queued 202", + }, + }); + expect(restored.status, JSON.stringify(restored.body).slice(0, 400)).toBe( + 200, + ); + + const after = columnsOf( + await prisma.user.findUniqueOrThrow({ where: { id: fresh.id } }), + ); + const lostInStorage = SETTING_COLUMNS.filter( + (column) => + !SEALED.has(column) && + comparable(after.get(column)) !== comparable(stored.get(column)), + ); + expect( + lostInStorage.map( + (column) => + `${column}: ${comparable(stored.get(column))} -> ${comparable(after.get(column))}`, + ), + "these columns came back with a different stored value", + ).toEqual([]); + + const restoredSession = await makeSession(fresh); + const failures: string[] = []; + for (const [index, entry] of entries(fresh).entries()) { + const read = await call(entry.read.call, restoredSession); + const value = + read.status === 200 ? entry.read.pick(read) : `status ${read.status}`; + if (!isDeepStrictEqual(value, before.get(index))) { + failures.push( + `${entry.columns.join(", ")}: read ${JSON.stringify(before.get(index))} before, ${JSON.stringify(value)} after`, + ); + } + } + const me = await callRoute(getMe, { + method: "GET", + route: "/api/auth/me", + auth: restoredSession, + }); + for (const [index, entry] of entries(fresh).entries()) { + if (!entry.me) continue; + const value = entry.me.pick(me.data); + if (!isDeepStrictEqual(value, meBefore.get(index))) { + failures.push( + `${entry.columns.join(", ")}: /api/auth/me had ${JSON.stringify(meBefore.get(index))}, now ${JSON.stringify(value)}`, + ); + } + } + expect(failures).toEqual([]); + }); + }); +});