Files
HealthLog/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx
T
Marc-André Bombeck 024da9fe96 fix(ui): granular sleep sources, viewport-safe tour, one aligned settings language
A night with coarse apple-health samples next to granular watch data
showed only awake and asleep — the per-session source pick now prefers
the source that knows its stages. Tour popovers clamp into the
viewport, wrap their footer and fall back to a bottom sheet on narrow
screens. Settings and admin sections share one alignment rule — icon
column left, everything else flush on the content column — with neutral
header icons, no duplicated section titles under the shell heading, and
every select on the account-style idiom with a visible chevron. The
dashboard's customize icon matches insights, the target-settings link
leaves the home charts, and the mood heatmap adopts the damped fills
the other mood charts already use.
2026-06-10 21:19:05 +02:00

410 lines
16 KiB
TypeScript

import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import {
DEFAULT_ASSISTANT_FLAGS,
type AssistantFlagSet,
} from "@/hooks/use-feature-flags";
import type { AuthUser } from "@/hooks/use-auth";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
import { HeroStrip } from "@/components/insights/hero-strip";
/**
* v1.4.47 W3 — per-user "Hide Coach" opt-out invariant.
*
* The Coach disable cascade (`coach-cascade.test.tsx`) pins the
* operator-level `flags.coach` gate. This sibling fixture pins the
* per-user `disableCoach` gate that sits BELOW the operator flag —
* either gate being off must hide every Coach affordance with no
* grey-out, no error, no inert button, no DOM trace.
*
* Mocking strategy mirrors the cascade fixture: spy `useFeatureFlags`
* to drive the operator matrix synchronously during SSR, spy
* `useAuth` to drive the per-user `disableCoach` field. Both spies
* are reset between iterations so a regression in either gate trips
* the invariant in isolation.
*/
const featureFlagsSpy = vi.fn<() => AssistantFlagSet>(
() => DEFAULT_ASSISTANT_FLAGS,
);
vi.mock("@/hooks/use-feature-flags", async () => {
const actual = await vi.importActual<
typeof import("@/hooks/use-feature-flags")
>("@/hooks/use-feature-flags");
return {
...actual,
useFeatureFlags: () => featureFlagsSpy(),
};
});
const authSpy = vi.fn<
() => { user: AuthUser | null; isAuthenticated: boolean }
>(() => ({ user: null, isAuthenticated: false }));
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return {
...actual,
useAuth: () => authSpy(),
};
});
// The Coach gates read user.disableCoach through `useDisableCoach`,
// which itself calls `useAuth()`. Mocking `useDisableCoach` directly
// would bypass the useAuth call site and make the spy assertion
// below trivially fail; instead we let the real `useDisableCoach`
// pass through and capture the underlying `useAuth` calls.
// v1.16.1 — the nudge-driven `<LayoutCoachFab>` reads the app router
// (push + pathname); SSR test renders have no router context.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights",
}));
function buildUser(disableCoach: boolean): AuthUser {
// Minimal `AuthUser` shape — the Coach gates only read
// `disableCoach`, but TypeScript requires the full interface to
// satisfy the mock's return type. Everything else is filler.
return {
id: "test-user-id",
username: "test",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
disableCoach,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
cycleTrackingEnabled: false,
};
}
function buildClient(): QueryClient {
return new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
}
function renderWithDisableCoach(
node: React.ReactNode,
disableCoach: boolean,
coach: boolean = true,
): string {
featureFlagsSpy.mockImplementation(() => ({
...DEFAULT_ASSISTANT_FLAGS,
coach,
}));
authSpy.mockImplementation(() => ({
user: buildUser(disableCoach),
isAuthenticated: true,
}));
const client = buildClient();
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
interface DisableCoachSurface {
name: string;
mount: () => React.ReactNode;
/**
* Substring that proves the surface painted. Empty string means
* "the surface SSR-renders to nothing even when on" (lazy-loaded
* subtrees) and the negative assertion is the dispositive check.
*/
proofWhenVisible: string;
}
const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [
{
name: "LayoutCoachFab",
mount: () => (
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>
),
// v1.16.1 — nudge-driven: SSR shape is empty even when visible
// (the bubble waits for the nudge-status query). Negative
// assertion still pins the gate via the `coach-*` slot grep.
proofWhenVisible: "",
},
{
name: "LayoutCoachMount",
mount: () => (
<CoachLaunchProvider>
<LayoutCoachMount />
</CoachLaunchProvider>
),
// Lazy-loaded — SSR shape is empty even when visible. Negative
// assertion still pins the gate via the `coach-*` slot grep.
proofWhenVisible: "",
},
{
name: "CoachLaunchButton inline pill",
mount: () => (
<CoachLaunchProvider>
<CoachLaunchButton />
</CoachLaunchProvider>
),
proofWhenVisible: 'data-slot="coach-launch-inline"',
},
{
name: "SuggestedPrompts chip strip",
mount: () => <SuggestedPrompts onPick={() => undefined} />,
proofWhenVisible: 'data-slot="insights-suggested-prompts"',
},
{
name: "HeroStrip action-row Ask the coach",
mount: () => (
<HeroStrip
briefing={null}
now={new Date(2026, 4, 10, 9, 0, 0)}
onAskCoach={() => undefined}
/>
),
proofWhenVisible: 'data-slot="insights-hero-strip-action-coach"',
},
];
describe("Coach per-user disableCoach invariant", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
featureFlagsSpy.mockClear();
authSpy.mockClear();
featureFlagsSpy.mockImplementation(() => DEFAULT_ASSISTANT_FLAGS);
authSpy.mockImplementation(() => ({
user: buildUser(false),
isAuthenticated: true,
}));
});
for (const surface of DISABLE_COACH_SURFACES) {
it(`${surface.name} — paints when user.disableCoach is false`, () => {
if (!surface.proofWhenVisible) return;
const html = renderWithDisableCoach(surface.mount(), false);
expect(html).toContain(surface.proofWhenVisible);
});
it(`${surface.name} — vanishes when user.disableCoach is true`, () => {
const html = renderWithDisableCoach(surface.mount(), true);
if (surface.proofWhenVisible) {
expect(html).not.toContain(surface.proofWhenVisible);
}
// No Coach-marked mount point may paint when the per-user gate
// is on. Pin the negative shape against the shared prefix so a
// future surface that grows a new `coach-*` slot without the
// disableCoach gate fails this test by default.
expect(html).not.toMatch(/data-slot="coach-[a-z]/);
expect(html).not.toContain('data-slot="insights-suggested-prompts"');
expect(html).not.toContain(
'data-slot="insights-hero-strip-action-coach"',
);
expect(html).not.toContain('data-slot="insights-hero-strip-prompts"');
});
it(`${surface.name} — also vanishes when flags.coach is false (regression pin)`, () => {
// The legacy operator-level gate must keep working in the
// presence of the new per-user gate. This guards against a
// regression where a contributor "simplifies" the two checks
// into one and accidentally drops the operator branch.
const html = renderWithDisableCoach(surface.mount(), false, false);
if (surface.proofWhenVisible) {
expect(html).not.toContain(surface.proofWhenVisible);
}
expect(html).not.toMatch(/data-slot="coach-[a-z]/);
});
}
it("`useAuth` gate is consulted on every Coach surface", () => {
// A `disableCoach: true` render must trip the `useAuth` spy at
// least once. Otherwise a future regression that drops the
// `useAuth()` call site silently leaks the affordance.
for (const surface of DISABLE_COACH_SURFACES) {
authSpy.mockClear();
renderWithDisableCoach(surface.mount(), true);
expect(
authSpy,
`${surface.name} did not consult useAuth() during render`,
).toHaveBeenCalled();
}
});
// ──────────────────────────────────────────────────────────────────
// v1.4.48 H2 — grep-based discovery of every `useDisableCoach` /
// `user.disableCoach` call site in the codebase.
//
// Mirrors the `flags.coach` discovery walk in `coach-cascade.test.tsx`:
// the surface fixture above pins the surfaces this file mounts
// directly. Cross-cut gates (Settings → Insights toggle, `/api/auth/me`
// response shape, the disable-coach PATCH route) sit OUTSIDE the
// fixture and are owned by sibling invariants or by route-level tests.
//
// The walk below scans `src/` for every `useDisableCoach` /
// `user.disableCoach` / `user?.disableCoach` occurrence and requires
// each path to appear in the explicit `KNOWN_DISABLE_COACH_GATE_SITES`
// allowlist. Add a new entry every time you intentionally read the
// per-user disable flag in a new path; the failure message names the
// unaccounted-for paths so the fix is one search-and-add.
// ──────────────────────────────────────────────────────────────────
/**
* Every file in `src/` that legitimately reads `useDisableCoach` or
* `user.disableCoach`. Lives next to the disable-cascade fixture so a
* contributor extending the fixture sees the allowlist in the same
* review.
*
* Paths are POSIX-style relative to the repo root.
*/
const KNOWN_DISABLE_COACH_GATE_SITES: ReadonlyArray<string> = [
// Coach-bearing surfaces mounted directly by the disable-cascade
// fixture above; each one calls `useDisableCoach()` and short-
// circuits to `null` when the per-user flag is on.
"src/components/insights/coach-launch-button.tsx",
"src/components/insights/hero-strip.tsx",
"src/components/insights/layout-coach-fab.tsx",
"src/components/insights/layout-coach-mount.tsx",
"src/components/insights/suggested-prompts.tsx",
// v1.12.0 — the full-page Coach route mirrors the launch-button /
// FAB gate: operator master flag OR per-user opt-out redirects the
// page back to `/insights` instead of painting a dead chat shell.
"src/app/insights/coach/page.tsx",
// The hook itself + its `useAuth`-backed reader.
"src/hooks/use-disable-coach.ts",
// Cross-cut gates owned by sibling invariants / route tests.
// - `/api/auth/me` ships the flag in the session payload so the
// client hooks can read it (`useAuth → useDisableCoach`). The
// sibling `/api/auth/me/disable-coach` PATCH endpoint that
// writes the flag does NOT read `user.disableCoach` (it reads
// `body.disableCoach`) and is intentionally not listed here.
"src/app/api/auth/me/route.ts",
// - Settings → Insights toggle reads + writes the flag.
"src/components/settings/ai-section.tsx",
// v1.7.0 W6 — the unified dashboard snapshot builder reads
// `user.disableCoach` to gate the embedded daily briefing to
// `briefingState: "disabled"`. Covered by the briefingState matrix
// in `src/lib/dashboard/__tests__/snapshot.test.ts`. The route is
// a thin pass-through of the resolved session user into the
// builder.
"src/lib/dashboard/snapshot.ts",
"src/app/api/dashboard/snapshot/route.ts",
];
/**
* Walk every TS / TSX file under `src/`, excluding test trees + the
* Prisma client output, and collect the relative paths that
* legitimately reference `useDisableCoach` or `user.disableCoach`.
* Any literal occurrence on a non-comment line counts so a
* contributor who paraphrases the read still trips this test.
*/
function findDisableCoachSites(): string[] {
const srcRoot = resolve(__dirname, "..", "..", "..");
const repoRoot = resolve(srcRoot, "..");
const hits: string[] = [];
function isRealGateLine(line: string): boolean {
// Trim once so leading whitespace doesn't fool the comment check.
const trimmed = line.trim();
if (trimmed.startsWith("//")) return false;
if (trimmed.startsWith("*")) return false;
// `useDisableCoach` covers both the hook import + the hook call.
if (line.includes("useDisableCoach")) return true;
// `user.disableCoach` / `user?.disableCoach` — the canonical
// per-user gate read on the auth user object. Strict match on
// `user` so we don't grab unrelated `disableCoach` strings (route
// body keys, i18n message ids, …).
if (/\buser\??\.disableCoach\b/.test(line)) return true;
return false;
}
function walk(dir: string): void {
const entries = readdirSync(dir);
for (const entry of entries) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === "node_modules" || entry === ".next") continue;
if (entry === "__tests__") continue;
// Skip the Prisma client output — the generator embeds the
// entire `prisma/schema.prisma` source as a JSON-escaped
// `inlineSchema` string and any schema doc-comment that
// mentions `disableCoach` would otherwise leak through here.
if (entry === "generated") continue;
walk(full);
continue;
}
if (!/\.(tsx|ts)$/.test(entry)) continue;
if (/\.test\.(tsx|ts)$/.test(entry)) continue;
const text = readFileSync(full, "utf8");
if (text.split("\n").some(isRealGateLine)) {
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
}
}
}
walk(srcRoot);
return hits.sort();
}
it("grep-based discovery pins every `useDisableCoach` / `user.disableCoach` call site", () => {
const discovered = findDisableCoachSites();
const allowlist = new Set(KNOWN_DISABLE_COACH_GATE_SITES);
const orphans = discovered.filter((path) => !allowlist.has(path));
expect(
orphans,
[
"Found `useDisableCoach` / `user.disableCoach` in file(s) not on",
"the KNOWN_DISABLE_COACH_GATE_SITES allowlist",
"(src/lib/feature-flags/__tests__/coach-user-disable.test.tsx).",
"Add a fixture entry (or a sibling invariant) for the new gate,",
"then add the path to the allowlist. Orphans:",
...orphans.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
// Sanity check the other direction — an allowlist entry that no
// longer references the gate is dead documentation and should be
// pruned in the same PR that removes the read.
const discoveredSet = new Set(discovered);
const stale = KNOWN_DISABLE_COACH_GATE_SITES.filter(
(path) => !discoveredSet.has(path),
);
expect(
stale,
[
"KNOWN_DISABLE_COACH_GATE_SITES references file(s) that no longer",
"use `useDisableCoach` / `user.disableCoach`. Remove the stale",
"allowlist entry:",
...stale.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
});
});