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([]); + }); + }); +});