mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The Modules hub listed Coach and Cycle as read-only "manage in X" deep-links because their enabled-state is owned outside `modulePreferencesJson` (Coach in Coach settings, Cycle in the Account card). Give both a real switch like every other module, driving the SAME canonical state instead of a second copy: - Coach → `PATCH /api/auth/me/disable-coach` (`User.disableCoach`, inverted); the switch reads `!disableCoach`. - Cycle → `PATCH /api/auth/me/cycle-prefs` (`cycleTrackingEnabled`); the switch reads the resolved per-user gate. Precedence stays honest. A module the operator turned off server-wide can never be re-enabled by a per-user toggle, so the switch renders disabled with a "disabled server-wide" hint. For Coach the operator layer is BOTH the module-availability blob AND the assistant master flag (`flags.coach`); for Cycle it is the module-availability blob. Each delegated row keeps a small "manage" deep-link beside its switch, since the canonical surface carries more than on/off (Coach cadence/memory; cycle goal, predictions, lengths). Flipping either switch invalidates `authMe()` so every gated surface re-gates live; the cycle flip also evicts its own `cyclePrefs`/`cycle` reads, matching the canonical cycle card. Reuses the existing endpoints — no new route, no OpenAPI change.
364 lines
12 KiB
TypeScript
364 lines
12 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 }),
|
|
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,
|
|
healthScoreExplainer: 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,
|
|
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");
|
|
});
|
|
});
|
|
});
|