From 2c4dec08f1ab700a9d4fb2698c38f07dff6cb30c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 10:52:03 +0200 Subject: [PATCH] test(ai): answer both switch readers from one source in flag mocks Eight files mocked `@/lib/feature-flags` with `getAssistantFlags` alone. The capability loader reads `loadAssistantSwitches`, so wherever it ran it threw and every capability resolved to `check_failed`. A shared factory now points both readers at the function the test drives. Only the config-change chain reached the loader. Its fixture means AI switched off by the operator; it now supplies the loader's other inputs and asserts `operator_disabled` on the digest, so a starved loader turns it red instead of passing on the failure path. --- .../helpers/assistant-switches-mock.ts | 63 +++++++++++++++++++ .../auth/me/modules/__tests__/route.test.ts | 16 ++--- .../summary/__tests__/module-gate.test.ts | 10 +-- .../user/ai-provider/__tests__/route.test.ts | 8 ++- ...onfig-change-is-not-a-health-event.test.ts | 24 ++++++- src/lib/jobs/__tests__/coach-nudge.test.ts | 8 ++- .../__tests__/insight-pregenerate.test.ts | 8 ++- .../__tests__/period-narrative-warm.test.ts | 8 ++- .../__tests__/status-cron-candidates.test.ts | 8 ++- 9 files changed, 123 insertions(+), 30 deletions(-) create mode 100644 src/__tests__/helpers/assistant-switches-mock.ts diff --git a/src/__tests__/helpers/assistant-switches-mock.ts b/src/__tests__/helpers/assistant-switches-mock.ts new file mode 100644 index 000000000..8d9244ceb --- /dev/null +++ b/src/__tests__/helpers/assistant-switches-mock.ts @@ -0,0 +1,63 @@ +/** + * A `@/lib/feature-flags` mock whose two readers can never disagree. + * + * The module has two readers of the operator's assistant switches: + * `getAssistantFlags()`, which the crons and a few routes read directly, and + * `loadAssistantSwitches()`, which the AI capability loader reads. A mock that + * stubs only the first leaves the second undefined, the loader throws, and + * every capability resolves to `check_failed`. Nothing goes red: a test that + * meant "AI on" quietly runs the "AI unavailable" branch instead, and a test + * that meant "AI off for reason X" passes for reason `check_failed`. + * + * So both readers answer from one source. Hand the factory the function the + * test already drives (or let it make one), and `loadAssistantSwitches` reads + * whatever that function currently returns: + * + * ```ts + * const getAssistantFlags = vi.fn(); + * vi.mock("@/lib/feature-flags", async () => + * (await import("@/__tests__/helpers/assistant-switches-mock")) + * .mockAssistantSwitches((...a) => getAssistantFlags(...a)), + * ); + * ``` + * + * `loadAssistantSwitches` is a plain function, not a `vi.fn`, so a + * `vi.resetAllMocks()` in the test cannot strip it back to `undefined`. + * `src/__tests__/feature-flags-mock-guard.test.ts` refuses a + * `vi.mock("@/lib/feature-flags", …)` that provides neither this helper nor + * `loadAssistantSwitches` by name. + */ +import { vi } from "vitest"; + +import type { AssistantFlagSet } from "@/lib/feature-flags"; + +/** Every switch on: what a fresh install reads. */ +export const ALL_SWITCHES_ON: AssistantFlagSet = Object.freeze({ + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + documentAi: true, +}); + +/** Every switch off, as an operator who turned the assistant off has it. */ +export const ALL_SWITCHES_OFF: AssistantFlagSet = Object.freeze({ + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + documentAi: false, +}); + +type SwitchReader = (...args: never[]) => unknown | Promise; + +export function mockAssistantSwitches( + getAssistantFlags: SwitchReader = vi.fn(async () => ({ + ...ALL_SWITCHES_ON, + })), +) { + return { + getAssistantFlags, + loadAssistantSwitches: async () => getAssistantFlags(), + }; +} diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts index c1dd6de7d..85e88b936 100644 --- a/src/app/api/auth/me/modules/__tests__/route.test.ts +++ b/src/app/api/auth/me/modules/__tests__/route.test.ts @@ -43,14 +43,14 @@ vi.mock("@/lib/rate-limit", () => ({ })); // Operator master flag = on by default; coach delegation keys off it. -// Partial mock: the rest of the module stays real. -vi.mock("@/lib/feature-flags", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getAssistantFlags: vi.fn().mockResolvedValue({ coach: true }), - }; -}); +// Partial mock: the rest of the module stays real, and both switch readers +// answer from the one the test drives. +vi.mock("@/lib/feature-flags", async (importOriginal) => ({ + ...(await importOriginal()), + ...( + await import("@/__tests__/helpers/assistant-switches-mock") + ).mockAssistantSwitches(vi.fn().mockResolvedValue({ coach: true })), +})); vi.mock("@/lib/cache/invalidate", async (importOriginal) => { const actual = diff --git a/src/app/api/dashboard/summary/__tests__/module-gate.test.ts b/src/app/api/dashboard/summary/__tests__/module-gate.test.ts index dfb98886d..90bbfee18 100644 --- a/src/app/api/dashboard/summary/__tests__/module-gate.test.ts +++ b/src/app/api/dashboard/summary/__tests__/module-gate.test.ts @@ -36,10 +36,12 @@ vi.mock("@/lib/modules/operator-availability", async (importOriginal) => { >(); return { ...actual, getOperatorModuleAvailability: vi.fn() }; }); -vi.mock("@/lib/feature-flags", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, getAssistantFlags: vi.fn() }; -}); +vi.mock("@/lib/feature-flags", async (importOriginal) => ({ + ...(await importOriginal()), + ...( + await import("@/__tests__/helpers/assistant-switches-mock") + ).mockAssistantSwitches(vi.fn()), +})); // The heavy builder is stubbed: this test pins the GATE, not the aggregate // (which has its own coverage). It returns one card per emitted kind. vi.mock("@/lib/cache/server-cache", () => ({ diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index 2a0803b10..9c5e5809b 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -48,9 +48,11 @@ vi.mock("@/lib/ai/server-provider-health", () => ({ readServerProviderHealth: vi.fn(), })); -vi.mock("@/lib/feature-flags", () => ({ - getAssistantFlags: vi.fn(), -})); +vi.mock("@/lib/feature-flags", async () => + ( + await import("@/__tests__/helpers/assistant-switches-mock") + ).mockAssistantSwitches(vi.fn()), +); vi.mock("@/lib/sharing/provider-work-authority", () => ({ providerWorkAuthorityForRecord: vi.fn(() => ({ origin: "owner" })), diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index d2681a915..07eddcec1 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -178,6 +178,8 @@ const fakeDb = vi.hoisted(() => { personalRecord: { findMany: none }, arrivalReaction: { findMany: none }, encounter: { findFirst: async () => null }, + // The AI capability loader's consent read: no receipts. + consentReceipt: { findMany: none }, }; }); @@ -215,17 +217,27 @@ vi.mock("@/lib/analytics/bp-in-target-fast-path", () => ({ computeBpInTargetFastPath: (...a: unknown[]) => computeBpInTargetFastPath(...a), })); -vi.mock("@/lib/feature-flags", () => ({ - getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a), -})); +vi.mock("@/lib/feature-flags", async () => + ( + await import("@/__tests__/helpers/assistant-switches-mock") + ).mockAssistantSwitches(() => getAssistantFlags()), +); vi.mock("@/lib/ai/provider", () => ({ hasAnyConfiguredProvider: (...a: unknown[]) => hasAnyConfiguredProvider(...a), + // The capability loader's presence probe: no provider on the record. + probeProviderChain: async () => ({ + entries: [], + localOcrEnabled: false, + managedBy: null, + }), })); vi.mock("@/lib/dashboard/meds-today", () => ({ buildMedsTodayBlock: (...a: unknown[]) => buildMedsTodayBlock(...a), })); vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap: (...a: unknown[]) => resolveModuleMap(...a), + // The operator offers every module; the AI capability loader reads it. + getOperatorModuleAvailability: async () => moduleMap(), })); vi.mock("@/lib/dashboard/score-rings", () => ({ buildScoreRingsBlock: (...a: unknown[]) => buildScoreRingsBlock(...a), @@ -448,6 +460,12 @@ describe("a settings change is never narrated as a health event", () => { expect(digest.score?.deltaReason).toBeNull(); expect(digest.score?.delta).toBe(snapshot.healthScore!.delta); expect(digest.score!.delta!).toBeLessThanOrEqual(-10); + // The fixture's AI is off because the operator switched it off, and the + // capability loader actually ran to say so. `check_failed` here would mean + // a mock starved the loader and the digest was assembled on a failure + // path nobody meant to test. + expect(digest.ai.coach.reason).toBe("operator_disabled"); + expect(digest.ai.reactionLines.reason).toBe("operator_disabled"); }); it("renders no delta chip when the recipe changed inside the window", async () => { diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts index bb702bb17..8543a4340 100644 --- a/src/lib/jobs/__tests__/coach-nudge.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge.test.ts @@ -39,9 +39,11 @@ import { type HealthProfileAiSection, } from "@/lib/validations/health-profile-facts"; -vi.mock("@/lib/feature-flags", () => ({ - getAssistantFlags: vi.fn(async () => ({ coach: true })), -})); +vi.mock("@/lib/feature-flags", async () => + ( + await import("@/__tests__/helpers/assistant-switches-mock") + ).mockAssistantSwitches(vi.fn(async () => ({ coach: true }))), +); // Gate 3 resolves the `coach` capability per user; never the real resolver. vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForJob: vi.fn(), diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index abfb2aac3..0d0026a0a 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -40,9 +40,11 @@ vi.mock("@/lib/logging/context", async () => { annotate: (...a: unknown[]) => annotateSpy(...a), }; }); -vi.mock("@/lib/feature-flags", () => ({ - getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a), -})); +vi.mock("@/lib/feature-flags", async () => + ( + await import("@/__tests__/helpers/assistant-switches-mock") + ).mockAssistantSwitches(() => getAssistantFlags()), +); // Per-user capabilities, keyed by capability; every test starts with both // halves available and overrides one or both. type CapabilityState = { diff --git a/src/lib/jobs/__tests__/period-narrative-warm.test.ts b/src/lib/jobs/__tests__/period-narrative-warm.test.ts index 6f11e6f0d..58e270c6d 100644 --- a/src/lib/jobs/__tests__/period-narrative-warm.test.ts +++ b/src/lib/jobs/__tests__/period-narrative-warm.test.ts @@ -8,9 +8,11 @@ const getAssistantFlags = vi.fn(); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: (...a: unknown[]) => checkRateLimit(...a), })); -vi.mock("@/lib/feature-flags", () => ({ - getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a), -})); +vi.mock("@/lib/feature-flags", async () => + ( + await import("@/__tests__/helpers/assistant-switches-mock") + ).mockAssistantSwitches(() => getAssistantFlags()), +); // Never reach the real generator (which imports the provider chain). vi.mock("@/lib/insights/narrative/period-narrative-generate", () => ({ generatePeriodNarrative: vi.fn(), diff --git a/src/lib/jobs/__tests__/status-cron-candidates.test.ts b/src/lib/jobs/__tests__/status-cron-candidates.test.ts index 8ede8ce2d..fcd97978a 100644 --- a/src/lib/jobs/__tests__/status-cron-candidates.test.ts +++ b/src/lib/jobs/__tests__/status-cron-candidates.test.ts @@ -17,9 +17,11 @@ import fs from "node:fs"; import path from "node:path"; const getAssistantFlags = vi.fn(); -vi.mock("@/lib/feature-flags", () => ({ - getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a), -})); +vi.mock("@/lib/feature-flags", async () => + ( + await import("@/__tests__/helpers/assistant-switches-mock") + ).mockAssistantSwitches(() => getAssistantFlags()), +); import { findStatusCronCandidates } from "../status-cron-candidates"; import { PREGENERATE_STALE_MS } from "../insight-pregenerate";