Files
HealthLog/tests/integration/settings-round-trip.test.ts
T
Marc-André Bombeck 2ae23f4ca8 refactor(settings): remove the preview-feature opt-ins
The per-account preview switch was kept for the topic overview pages,
which were dropped before they shipped. Its one flag was stored, served
and backed up, and read by nothing; the column-reader audit listed it as
a pending consumer that will not come.

Removed completely:

- `/api/settings/preview-features` (GET, PATCH) is retired: it answers
  410 Gone with `meta.errorCode: route.retired` and `removedIn: 1.46.0`,
  and the contract publishes it under Retired. The route, its validation
  schema, the `PreviewFeatures` OpenAPI schemas and the settings module
  are deleted.
- Migration 0485 drops `users.preview_features_json` under a bounded lock
  wait, with the retry marker.
- The backup no longer writes the field. A file from v1.44 or v1.45 that
  carries it still restores: the schema accepts it and no codec reads it,
  so nothing is refused or written for it. The backup-parity goldens
  lose the field and apply one setting fewer.
- The data-wipe plan entry and the sharing-surface actor entry go with
  the route (frozen count 273 -> 272).

No client build reads the route: neither iOS repository nor any local
checkout references it.
2026-10-25 00:44:15 +02:00

1178 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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` (a codec in `ACCOUNT_SETTING_CODECS`,
* `src/lib/export/account-settings-backup.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/account-settings-backup";
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 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<typeof callRoute>[0];
interface Call {
handler: Handler;
method: HttpMethod;
route: string;
params?: Record<string, string>;
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<string, unknown>)[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<User, "id">): 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<string, unknown>;
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<Record<string, unknown>> | 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<Record<string, unknown>> | 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: ["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<Record<string, unknown>> | 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<string>([
"insuranceNumberEncrypted",
"homeLocationEncrypted",
]);
function columnsOf(user: User): Map<string, unknown> {
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<RouteResult> {
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<number, unknown>();
const meBefore = new Map<number, unknown>();
let stored: Map<string, unknown>;
let defaults: Map<string, unknown>;
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,
});
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([]);
});
});
});