Files
HealthLog/src/__tests__/managed-record-navigation.test.tsx
T
Marc-André Bombeck 71dbef1742 feat(settings): nine groups, module pages and one switch per state
Settings now reads as nine groups: Account, Tracking, Arrangement,
Notifications, Health profile, AI, Connections, Data and System. On a
phone one button names the current section and opens the grouped list,
replacing the strip of every section.

What moved, every old address still working:
- Sessions, trusted devices and security activity sit under Security;
  the privacy page anchors forward there.
- Notifications holds every reminder kind: the Coach nudges and the
  checkup reminder switches joined, and a card names the reminders set
  where their subject lives (dose times, cycle reminders).
- Arrangement holds order and view only. Mood tags, the labs biomarker
  catalog and on-device text recognition, and the injection sites live on
  module pages under /settings/modules/<id>, linked from each Modules row
  and from the module's own wrench. /settings/layout/mood redirects.
- The timezone saves on change in the Display card, with a one-tap offer
  of the device zone.
- Screening hints sit beside the life circumstances they come from.
- System gains Preview features with the Topics switch.

Duplicates: the Coach and MCP pages show their state and link to the one
switch in Modules (turning MCP off keeps tokens), the Coach overlay shows
the provider and model with a link to Settings > AI instead of a second
set of selects, and the privacy page drops its copy of the export links.

A guard walks every historical /settings address through the redirects
and every forwarded anchor to a page that renders.
2026-10-21 19:43:02 +02:00

191 lines
6.8 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { classifySettingsDestination } from "@/lib/record-settings";
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
const pathnameRef = { value: "/settings/security" };
const activeRecordRef = { value: true };
/** The record on screen. `managed` unless a case says otherwise. */
const recordRef: { value: { kind: "managed" | "shared"; level: string } } = {
value: { kind: "managed", level: "manage" },
};
vi.mock("next/navigation", () => ({
usePathname: () => pathnameRef.value,
useRouter: () => ({ replace: () => {}, push: () => {} }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: activeRecordRef.value
? {
id: "guardian",
modules: undefined,
accountAccess: {
active: {
accountId: "managed-record",
username: "managed-record",
displayName: "Managed record",
access: "write",
level: recordRef.value.level,
recordKind: recordRef.value.kind,
sections: null,
canWrite: true,
// Bound as the server publishes it for a whole-record grant.
manageableDomains: delegatedDomains(
recordRef.value.level as "read" | "write" | "manage",
null,
"manage",
),
},
accounts: [],
canSwitch: true,
},
}
: null,
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/use-account-switch", () => ({
useAccountSwitch: () => ({
mutate: vi.fn(),
isPending: false,
}),
}));
import {
SETTINGS_SECTIONS,
SettingsShell,
} from "@/components/settings/settings-shell";
import { SharedRecordBanner } from "@/components/layout/shared-record-banner";
function renderSettings(pathname = "/settings/security") {
pathnameRef.value = pathname;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<SettingsShell>
<div>managed settings content</div>
</SettingsShell>
</I18nProvider>,
);
}
/**
* The destinations a record offers, by what kind of record it is.
*
* v1.37.0 — a managed profile offers guardian configuration AND the record
* content a MANAGE holder may write, because a Guardian holds MANAGE. The
* `manage-writable` kind is what carries the second half; before it existed
* the anamnesis form had no page a Guardian could open, while the routes
* behind it had admitted the write since the level shipped.
*/
const MANAGED_KINDS = new Set(["managed-guardian", "manage-writable"]);
describe("managed record navigation", () => {
it("shows only managed settings destinations while retaining an unavailable deep-link heading", () => {
recordRef.value = { kind: "managed", level: "manage" };
const html = renderSettings();
const managedDestinations = SETTINGS_SECTIONS.filter((section) =>
MANAGED_KINDS.has(classifySettingsDestination(section.slug).kind),
);
const unavailableDestinations = SETTINGS_SECTIONS.filter(
(section) =>
!MANAGED_KINDS.has(classifySettingsDestination(section.slug).kind),
);
// The partition is non-empty on both sides. A classification change that
// emptied either would make one of the loops below prove nothing.
expect(managedDestinations.length).toBeGreaterThan(1);
expect(unavailableDestinations.length).toBeGreaterThan(1);
// And the destination this release opened is really on the offered side.
expect(managedDestinations.map((s) => s.slug)).toContain("anamnesis");
expect(html).toContain('data-active-record-id="managed-record"');
expect(html).toContain('id="settings-section-security-title"');
expect(html).not.toMatch(/href="[^"]*managed-record/);
for (const section of managedDestinations) {
expect(
html.match(new RegExp(`href="/settings/${section.slug}"`, "g"))
?.length ?? 0,
// The sidebar carries the link; the phone's section sheet mounts its
// list only when opened (v1.44).
).toBe(1);
}
for (const section of unavailableDestinations) {
expect(html).not.toContain(`href="/settings/${section.slug}"`);
}
});
it("offers an adult manager the record content and none of the configuration", () => {
// The other half of the same classification, and the half that is easiest
// to get wrong in the generous direction: a delegate manages somebody's
// health record, not their account. Anamnese is offered; modules,
// thresholds and notification routing are not, even though a Guardian
// reaches all three.
recordRef.value = { kind: "shared", level: "manage" };
const html = renderSettings();
expect(html).toContain('href="/settings/anamnesis"');
for (const guardianOnly of [
"account",
"modules",
"notifications",
"thresholds",
"coach",
]) {
expect(html, guardianOnly).not.toContain(
`href="/settings/${guardianOnly}"`,
);
}
});
it("offers a WRITE delegate no settings destination at all", () => {
// The refusal side. `manage-writable` is admitted by LEVEL, not by being
// in a shared record: a WRITE grant reaches no Settings page, and a
// destination it could see would be one the route below refuses.
recordRef.value = { kind: "shared", level: "write" };
const html = renderSettings();
expect(html).not.toContain('href="/settings/anamnesis"');
for (const section of SETTINGS_SECTIONS) {
expect(html, section.slug).not.toContain(
`href="/settings/${section.slug}"`,
);
}
});
it("offers a managed entry below MANAGE nothing at all", () => {
// A guardian grant is always MANAGE today, which is exactly why the level
// check was easy to leave out of the managed branch. Without it a managed
// entry that ever arrived below MANAGE would list destinations the section
// gate refuses, sending somebody to a page that explains it cannot open.
recordRef.value = { kind: "managed", level: "read" };
const html = renderSettings();
for (const section of SETTINGS_SECTIONS) {
expect(html, section.slug).not.toContain(
`href="/settings/${section.slug}"`,
);
}
});
it("keeps the exit control present while administering a managed record", () => {
recordRef.value = { kind: "managed", level: "manage" };
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<SharedRecordBanner />
</I18nProvider>,
);
expect(html).toContain('data-account-id="managed-record"');
expect(html).toContain('data-slot="shared-record-banner-exit"');
});
});