mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The admin panel offered a toggle for the Health-Score explainer and described it as the ? next to the delta line. That ? had already been taken off the insights surface a release earlier, and the caption that replaced it went away entirely when the health score was replaced by the reference composite. Since then an operator could turn the switch off, get a saved confirmation, and change absolutely nothing. The toggle, its wording in all six languages, its place in the flag matrix the app and the iOS client both read, and its column on app_settings are all gone. Nothing is left to promise a control that does not exist. A guard goes in with it: every remaining assistant sub-flag has to be read by some surface, and the panel and the plumbing that carry all of them by construction do not count as proof.
372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
/**
|
|
* v1.18.0 — Settings → Module ("Was du trackst") hub contract.
|
|
*
|
|
* `<ModulesSection>` lists every toggleable module with a `<Switch>` bound
|
|
* to the resolved module map (`useAuth().user.modules`), plus a read-only
|
|
* "always on" group for the four core domains. Flipping a toggle PATCHes
|
|
* `/api/auth/me/modules` (a DISABLED allowlist) and invalidates the
|
|
* `authMe()` query so nav / pills / tiles re-gate live. The two delegated
|
|
* modules (coach, cycle) render a live switch too, but their flip drives the
|
|
* canonical column (`disableCoach` / `cycleTrackingEnabled`) via the existing
|
|
* per-column endpoints — never the module-allowlist the gate ignores for them.
|
|
*
|
|
* Test strategy mirrors `advanced-research-mode.test.tsx`: mock
|
|
* `@tanstack/react-query` so the rendered tree executes under SSR
|
|
* (`renderToStaticMarkup`), capture the `useMutation` config to assert the
|
|
* PATCH body + the `authMe()` invalidation directly, and mock `useAuth` to
|
|
* drive the disabled-module case.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
import { I18nProvider } from "@/lib/i18n/context";
|
|
import type { AuthUser } from "@/hooks/use-auth";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import { MODULE_KEYS, isCodeDisabledModule } from "@/lib/modules/registry";
|
|
|
|
// Capture the latest useMutation config + a shared mutate spy so we can
|
|
// invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a
|
|
// real DOM click).
|
|
type MutationConfig = {
|
|
mutationFn?: (vars: unknown) => Promise<unknown>;
|
|
onSuccess?: (data?: unknown, variables?: unknown) => void;
|
|
onError?: (err?: unknown) => void;
|
|
};
|
|
let lastMutation: MutationConfig | null = null;
|
|
const mutateSpy = vi.fn();
|
|
const invalidateSpy = vi.fn();
|
|
|
|
vi.mock("@tanstack/react-query", () => ({
|
|
useQueryClient: () => ({
|
|
invalidateQueries: invalidateSpy,
|
|
// v1.32.22 (M3) — the writer reads/seeds the optimistic-concurrency token
|
|
// via the query cache; no token seeded → tokenless (unconditional) write.
|
|
getQueryData: () => undefined,
|
|
setQueryData: vi.fn(),
|
|
removeQueries: vi.fn(),
|
|
}),
|
|
useMutation: (config: MutationConfig) => {
|
|
lastMutation = config;
|
|
return { mutate: mutateSpy, isPending: false };
|
|
},
|
|
}));
|
|
|
|
const authSpy = vi.fn<
|
|
() => { user: AuthUser | null; isAuthenticated: boolean }
|
|
>(() => ({ user: buildUser({}), isAuthenticated: true }));
|
|
vi.mock("@/hooks/use-auth", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@/hooks/use-auth")>(
|
|
"@/hooks/use-auth",
|
|
);
|
|
return { ...actual, useAuth: () => authSpy() };
|
|
});
|
|
|
|
// The hub now renders a live coach switch, so it reads the operator assistant
|
|
// flag matrix (`flags.coach`) to decide whether the operator killed Coach
|
|
// server-wide. Mock the hook directly so the SSR pass doesn't need a
|
|
// `<QueryClientProvider>`; default all-on.
|
|
import type { AssistantFlagSet } from "@/hooks/use-feature-flags";
|
|
const ALL_ON_FLAGS: AssistantFlagSet = {
|
|
enabled: true,
|
|
coach: true,
|
|
briefing: true,
|
|
insightStatus: true,
|
|
correlations: true,
|
|
};
|
|
const flagsSpy = vi.fn<() => AssistantFlagSet>(() => ALL_ON_FLAGS);
|
|
vi.mock("@/hooks/use-feature-flags", () => ({
|
|
useFeatureFlags: () => flagsSpy(),
|
|
}));
|
|
|
|
function buildUser(
|
|
modules: AuthUser["modules"],
|
|
overrides: Partial<AuthUser> = {},
|
|
): AuthUser {
|
|
return {
|
|
id: "user-1",
|
|
username: "testuser",
|
|
email: null,
|
|
role: "USER",
|
|
heightCm: null,
|
|
dateOfBirth: null,
|
|
gender: null,
|
|
timezone: "Europe/Berlin",
|
|
onboardingCompletedAt: null,
|
|
onboardingTourCompleted: true,
|
|
onboardingTourProgress: null,
|
|
avatarUrl: null,
|
|
glucoseUnit: null,
|
|
unitPreference: "metric",
|
|
timeFormat: "AUTO",
|
|
dateFormat: "AUTO",
|
|
disableCoach: false,
|
|
fullName: null,
|
|
insurerName: null,
|
|
insurerIkNumber: null,
|
|
insuranceNumber: null,
|
|
lastReportPracticeName: null,
|
|
reportSelection: null,
|
|
cycleTrackingEnabled: false,
|
|
modules,
|
|
moduleAvailability: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
import { ModulesSection } from "../modules-section";
|
|
|
|
function render(locale: "en" | "de" = "en"): string {
|
|
return renderToStaticMarkup(
|
|
<I18nProvider initialLocale={locale}>
|
|
<ModulesSection />
|
|
</I18nProvider>,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
lastMutation = null;
|
|
mutateSpy.mockClear();
|
|
invalidateSpy.mockClear();
|
|
authSpy.mockImplementation(() => ({
|
|
user: buildUser({}),
|
|
isAuthenticated: true,
|
|
}));
|
|
flagsSpy.mockImplementation(() => ALL_ON_FLAGS);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("<ModulesSection>", () => {
|
|
it("renders a live Switch for every toggleable module, including the delegated ones", () => {
|
|
// v1.18.6 (W9) — the "Always on" core-domains card was removed (a domain
|
|
// that can't be turned off doesn't need listing), so this section now
|
|
// only renders the toggleable modules.
|
|
const html = render();
|
|
for (const key of MODULE_KEYS) {
|
|
if (isCodeDisabledModule(key)) {
|
|
// v1.25.3 — a module switched off in code (pending a rebuild) drops
|
|
// out of the hub entirely: no Switch, no deep-link, no row.
|
|
expect(html, `code-disabled has no switch ${key}`).not.toContain(
|
|
`id="module-toggle-${key}"`,
|
|
);
|
|
} else {
|
|
// Delegated modules (coach, cycle) now render a real Switch too — it
|
|
// drives their canonical column — alongside a "manage" deep-link.
|
|
expect(html, `toggleable switch ${key}`).toContain(
|
|
`id="module-toggle-${key}"`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("keeps a manage deep-link beside the delegated coach + cycle switches", () => {
|
|
const html = render();
|
|
// Coach → Coach settings; cycle → the Account cycle-tracking card.
|
|
expect(html).toContain('href="/settings/coach"');
|
|
expect(html).toContain('href="/settings/account#cycle-tracking"');
|
|
});
|
|
|
|
it("no longer renders the 'Always on' core-domains card", () => {
|
|
// v1.18.6 (W9) — the read-only core card (weight / BP / pulse) was
|
|
// removed; the core domains are always active regardless.
|
|
const html = render();
|
|
for (const key of ["weight", "bloodPressure", "pulse"]) {
|
|
expect(html, `core ${key} not rendered`).not.toContain(
|
|
`id="module-toggle-${key}"`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("seeds an enabled module checked (default-on) and a disabled one unchecked", () => {
|
|
authSpy.mockImplementation(() => ({
|
|
user: buildUser({ sleep: false }),
|
|
isAuthenticated: true,
|
|
}));
|
|
const html = render();
|
|
|
|
// The disabled `sleep` toggle surfaces data-state="unchecked".
|
|
const sleepTag = html.match(
|
|
/<button[^>]*id="module-toggle-sleep"[^>]*>/,
|
|
)?.[0];
|
|
expect(sleepTag).toBeDefined();
|
|
expect(sleepTag).toMatch(/data-state="unchecked"/);
|
|
|
|
// An absent key (`mood`) reads as enabled → checked.
|
|
const moodTag = html.match(
|
|
/<button[^>]*id="module-toggle-mood"[^>]*>/,
|
|
)?.[0];
|
|
expect(moodTag).toMatch(/data-state="checked"/);
|
|
});
|
|
|
|
it("PATCHes only the flipped key as a disabled-allowlist entry", async () => {
|
|
render();
|
|
expect(lastMutation?.mutationFn).toBeTypeOf("function");
|
|
|
|
const fetchSpy = vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ data: { modules: {} } }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
|
|
await lastMutation!.mutationFn!({ key: "glucose", enabled: false });
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchSpy.mock.calls[0] as unknown as [
|
|
string,
|
|
RequestInit,
|
|
];
|
|
expect(url).toContain("/api/auth/me/modules");
|
|
expect(init.method).toBe("PATCH");
|
|
expect(JSON.parse(init.body as string)).toEqual({ glucose: false });
|
|
});
|
|
|
|
it("invalidates the authMe query on a successful toggle", () => {
|
|
render();
|
|
expect(lastMutation?.onSuccess).toBeTypeOf("function");
|
|
|
|
// onSuccess reads `variables.key` to decide the extra evictions; a
|
|
// non-delegated key only touches authMe.
|
|
lastMutation!.onSuccess!(undefined, { key: "glucose", enabled: false });
|
|
|
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
|
queryKey: queryKeys.authMe(),
|
|
});
|
|
});
|
|
|
|
describe("delegated coach row", () => {
|
|
it("reflects `!disableCoach`: checked when Coach is active, unchecked when hidden", () => {
|
|
authSpy.mockImplementation(() => ({
|
|
user: buildUser({}, { disableCoach: false }),
|
|
isAuthenticated: true,
|
|
}));
|
|
let coachTag = render().match(
|
|
/<button[^>]*id="module-toggle-coach"[^>]*>/,
|
|
)?.[0];
|
|
expect(coachTag).toMatch(/data-state="checked"/);
|
|
|
|
authSpy.mockImplementation(() => ({
|
|
user: buildUser({}, { disableCoach: true }),
|
|
isAuthenticated: true,
|
|
}));
|
|
coachTag = render().match(
|
|
/<button[^>]*id="module-toggle-coach"[^>]*>/,
|
|
)?.[0];
|
|
expect(coachTag).toMatch(/data-state="unchecked"/);
|
|
});
|
|
|
|
it("writes the inverted `disableCoach` to the canonical endpoint", async () => {
|
|
render();
|
|
const fetchSpy = vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ data: { disableCoach: true } }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
|
|
// Turning the hub switch OFF (enabled:false) writes disableCoach:true.
|
|
await lastMutation!.mutationFn!({ key: "coach", enabled: false });
|
|
|
|
const [url, init] = fetchSpy.mock.calls[0] as unknown as [
|
|
string,
|
|
RequestInit,
|
|
];
|
|
expect(url).toContain("/api/auth/me/disable-coach");
|
|
expect(init.method).toBe("PATCH");
|
|
expect(JSON.parse(init.body as string)).toEqual({ disableCoach: true });
|
|
});
|
|
|
|
it("disables the switch + shows a hint when the operator killed Coach", () => {
|
|
// Operator assistant master flag off → coach is unavailable server-wide.
|
|
flagsSpy.mockImplementation(() => ({ ...ALL_ON_FLAGS, coach: false }));
|
|
const html = render();
|
|
const coachTag = html.match(
|
|
/<button[^>]*id="module-toggle-coach"[^>]*>/,
|
|
)?.[0];
|
|
expect(coachTag).toMatch(/disabled/);
|
|
expect(html).toContain("Disabled server-wide");
|
|
});
|
|
|
|
it("also disables the switch when the module-availability blob kills Coach", () => {
|
|
authSpy.mockImplementation(() => ({
|
|
user: buildUser({}, { moduleAvailability: { coach: false } }),
|
|
isAuthenticated: true,
|
|
}));
|
|
const coachTag = render().match(
|
|
/<button[^>]*id="module-toggle-coach"[^>]*>/,
|
|
)?.[0];
|
|
expect(coachTag).toMatch(/disabled/);
|
|
});
|
|
});
|
|
|
|
describe("delegated cycle row", () => {
|
|
it("reflects the resolved `cycleTrackingEnabled`", () => {
|
|
authSpy.mockImplementation(() => ({
|
|
user: buildUser({}, { cycleTrackingEnabled: true }),
|
|
isAuthenticated: true,
|
|
}));
|
|
const cycleTag = render().match(
|
|
/<button[^>]*id="module-toggle-cycle"[^>]*>/,
|
|
)?.[0];
|
|
expect(cycleTag).toMatch(/data-state="checked"/);
|
|
});
|
|
|
|
it("writes `{ enabled }` to the cycle-prefs endpoint", async () => {
|
|
render();
|
|
const fetchSpy = vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ data: { enabled: true } }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
|
|
await lastMutation!.mutationFn!({ key: "cycle", enabled: true });
|
|
|
|
const [url, init] = fetchSpy.mock.calls[0] as unknown as [
|
|
string,
|
|
RequestInit,
|
|
];
|
|
expect(url).toContain("/api/auth/me/cycle-prefs");
|
|
expect(init.method).toBe("PATCH");
|
|
expect(JSON.parse(init.body as string)).toEqual({ enabled: true });
|
|
});
|
|
|
|
it("evicts cyclePrefs + cycle + authMe on a successful cycle toggle", () => {
|
|
render();
|
|
lastMutation!.onSuccess!(undefined, { key: "cycle", enabled: true });
|
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
|
queryKey: queryKeys.authMe(),
|
|
});
|
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
|
queryKey: queryKeys.cyclePrefs(),
|
|
});
|
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
|
queryKey: queryKeys.cycle(),
|
|
});
|
|
});
|
|
|
|
it("disables the switch + shows a hint when the operator killed cycle", () => {
|
|
authSpy.mockImplementation(() => ({
|
|
user: buildUser({}, { moduleAvailability: { cycle: false } }),
|
|
isAuthenticated: true,
|
|
}));
|
|
const html = render();
|
|
const cycleTag = html.match(
|
|
/<button[^>]*id="module-toggle-cycle"[^>]*>/,
|
|
)?.[0];
|
|
expect(cycleTag).toMatch(/disabled/);
|
|
expect(html).toContain("Disabled server-wide");
|
|
});
|
|
});
|
|
});
|