Files
HealthLog/src/__tests__/record-settings-contract.test.ts
T
Marc-André Bombeck e2ba86b12f feat(garmin): sync Garmin Connect through the Personal API
Garmin Connect now offers a Personal API: a person creates a key for
their own account and any client can buy short-lived tokens with it.
HealthLog takes that key in a new Garmin card, checks it with Garmin
before storing it sealed on the user row, and polls every hour.

- Transport: key exchange with a per-process token cache keyed on the
  key, one re-exchange on a refused token and then reauth_required, the
  429 Retry-After carried to the caller, the China host tried when the
  global one does not know the key, answers read with a byte cap, all
  egress through safeFetch to the two fixed hosts.
- Sync: daily summaries, sleep summaries, HRV, weigh-ins and the
  activity search, chunked to each resource's documented range, read one
  after another and stopped at the first rate limit; today's menstrual
  cycle summary adds logged period days the person has not logged.
- Backfill: a resumable walk through the shared admission lane that
  learns where the history starts from the daily summaries and the
  activities, waits out Garmin's rate limit and stamps its marker.
- Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own
  day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS
  and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest
  token. All four chart on the recovery page and sit on the ladders,
  units, ranges, labels and report groups like the device-native scores.
- Key expiry: learned from a token capped short of a day, else estimated
  from the validity picked at connect; the card warns a week ahead and
  asks for a new key once Garmin refuses the old one.
- Ledger, health, freshness, watchdog, backup classes, wipe plan,
  encrypted-column registry, rotation script, OpenAPI and all ten
  locales carry the new integration. The old "no Garmin connector" note
  is gone.
2026-11-04 10:14:10 +01:00

220 lines
7.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
assertRecordSettingsAccess,
type RecordSettingsAccess,
} from "@/lib/record-settings/access";
import {
classifySettingsDestination,
isGuardianSettingsWriteAllowed,
isManageDelegateSettingsDestination,
SETTINGS_DESTINATION_INVENTORY,
} from "@/lib/record-settings/classification";
import { RECORD_CONTENT_SECTIONS } from "@/components/settings/record-settings-section-gate";
import { toRecordSettingsDto } from "@/lib/record-settings/dto";
import { resolveManagedIntegrationState } from "@/lib/record-settings/integrations";
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response";
import { recordSettingsKeys } from "@/lib/query-keys";
describe("record settings contract", () => {
it("names the target record in both the DTO and cache key", () => {
const dto = toRecordSettingsDto({
id: "record-1",
name: "Managed profile",
locale: "de",
timezone: "Europe/Berlin",
recordKind: "managed",
});
expect(dto).toEqual({
record: {
id: "record-1",
displayName: "Managed profile",
locale: "de",
timezone: "Europe/Berlin",
kind: "managed",
},
});
expect(recordSettingsKeys.detail("record-1")).toEqual([
"record-settings",
"record-1",
"detail",
]);
expect(recordSettingsKeys.recordSettingsIntegrations("record-1")).toEqual([
"record-settings",
"record-1",
"integrations",
]);
});
it("does not let an adult MANAGE grant use Guardian-only configuration", () => {
const access: RecordSettingsAccess = {
actorId: "adult-manager",
recordId: "managed-record",
recordKind: "managed",
relationship: "adult-manager",
};
expect(() => assertRecordSettingsAccess(access, "guardian")).toThrow(
"Guardian configuration is unavailable for this record",
);
});
it("classifies every Settings destination and limits Guardian writes", () => {
expect(Object.keys(SETTINGS_DESTINATION_INVENTORY)).not.toHaveLength(0);
expect(classifySettingsDestination("integrations")).toMatchObject({
kind: "managed-guardian",
guardianWritable: false,
});
expect(classifySettingsDestination("ai").kind).toBe("unavailable");
// v1.37.19 — "dashboard" left SETTINGS_SECTION_SLUGS (its URL is
// owned by a permanent redirect to /settings/layout/dashboard), so an
// unknown destination falls closed to plain unavailable; "layout" is
// the surviving adult-shared-unavailable representative.
expect(classifySettingsDestination("dashboard").kind).toBe("unavailable");
expect(classifySettingsDestination("layout").kind).toBe(
"adult-shared-unavailable",
);
expect(isGuardianSettingsWriteAllowed("account")).toBe(true);
expect(isGuardianSettingsWriteAllowed("notifications")).toBe(true);
expect(isGuardianSettingsWriteAllowed("integrations")).toBe(false);
expect(isGuardianSettingsWriteAllowed("ai")).toBe(false);
});
/**
* v1.37.0 — the anamnesis destination, which is the one piece of record
* CONTENT under `/settings`.
*
* `POST /api/allergies` and `POST /api/family-history` have resolved
* `requireRecordAuth("manage", "profile")` since MANAGE shipped, while the
* only form that posts to either was classified unavailable — an admitted
* write with no reachable caller. These three legs are the classification
* half of closing that; the integration file drives the routes themselves.
*/
it("opens the anamnesis destination to everybody the routes already admit", () => {
expect(classifySettingsDestination("anamnesis")).toEqual({
kind: "manage-writable",
guardianWritable: true,
});
// The Guardian half. Before this the destination existed and was
// read-only for the one person the profile exists for.
expect(isGuardianSettingsWriteAllowed("anamnesis")).toBe(true);
// The adult-delegate half, and its boundary in the same breath: a MANAGE
// grant opens the record's health background and nothing else under
// Settings. Record CONFIGURATION — modules, thresholds, notification
// routing — stays with the owner even though a Guardian may change it.
expect(isManageDelegateSettingsDestination("anamnesis")).toBe(true);
for (const guardianOnly of [
"account",
"modules",
"notifications",
"thresholds",
"insights",
"coach",
"integrations",
]) {
expect(
isManageDelegateSettingsDestination(guardianOnly),
guardianOnly,
).toBe(false);
}
});
it("gives every record-content destination a page of its own", () => {
// Both directions, and the first is the one that matters. `manage-writable`
// is a classification; the gate has to know which PAGE each slug is, or a
// destination added to the kind would silently inherit the allergy and
// family-history managers under its own heading. The map is what says
// which, and this is what stops the two drifting apart — a new
// manage-writable slug fails here, at the moment it is added, rather than
// rendering somebody else's forms.
const classified = Object.keys(SETTINGS_DESTINATION_INVENTORY)
.filter(isManageDelegateSettingsDestination)
.sort();
const rendered = Object.keys(RECORD_CONTENT_SECTIONS).sort();
expect(classified).not.toHaveLength(0);
expect(rendered).toEqual(classified);
});
it("leaves exactly one destination on the manage-writable list", () => {
// The list is short on purpose and a second entry is a consent decision,
// not a classification tidy-up. Pinning the count means a destination
// cannot join it as a side effect of somebody reclassifying a neighbour.
const manageWritable = Object.entries(SETTINGS_DESTINATION_INVENTORY)
.filter(([, value]) => value.kind === "manage-writable")
.map(([slug]) => slug);
expect(manageWritable).toEqual(["anamnesis"]);
});
it("does not report a synthetic ledger row as a managed connection", () => {
const connected = {
withings: false,
whoop: false,
fitbit: false,
nightscout: false,
polar: false,
oura: false,
"google-health": false,
strava: false,
dawarich: false,
garmin: false,
} as const;
expect(
resolveManagedIntegrationState("connected", connected, "withings"),
).toBe("disconnected");
expect(
resolveManagedIntegrationState(
"error_reauth",
{ ...connected, oura: true },
"oura",
),
).toBe("error_reauth");
});
it("reports a real credential with no ledger row as connected", () => {
// The connection map is the credential: this branch is only reached once
// it says a pipe exists. `unknown` on top of it means the sync ledger
// holds no row yet — an hourly provider connected minutes ago — and this
// projection's `state` is documented as "a pipe exists", not as liveness.
// Calling it "Not connected" invites a guardian to revoke the grant they
// just made.
const connected = {
withings: true,
whoop: false,
fitbit: false,
nightscout: false,
polar: false,
oura: false,
"google-health": false,
strava: false,
dawarich: false,
garmin: false,
} as const;
expect(
resolveManagedIntegrationState("unknown", connected, "withings"),
).toBe("connected");
// No credential still outranks it.
expect(resolveManagedIntegrationState("unknown", connected, "oura")).toBe(
"disconnected",
);
});
it("rejects a late response for another record in a second tab", () => {
expect(() =>
assertRecordSettingsResponseForRecord(
{ recordId: "managed-record-b" },
"managed-record-a",
),
).toThrow("did not match its key");
expect(() =>
assertRecordSettingsResponseForRecord(
{ recordId: "managed-record-a" },
"managed-record-a",
),
).not.toThrow();
});
});