mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
This commit is contained in:
1 parent
c2005fd8f1
commit
2c4dec08f1
9 files changed
+123
-30
No files matched your search
@@ -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<unknown>;
|
||||
|
||||
export function mockAssistantSwitches(
|
||||
getAssistantFlags: SwitchReader = vi.fn(async () => ({
|
||||
...ALL_SWITCHES_ON,
|
||||
})),
|
||||
) {
|
||||
return {
|
||||
getAssistantFlags,
|
||||
loadAssistantSwitches: async () => getAssistantFlags(),
|
||||
};
|
||||
}
|
||||
@@ -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<typeof import("@/lib/feature-flags")>();
|
||||
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<typeof import("@/lib/feature-flags")>()),
|
||||
...(
|
||||
await import("@/__tests__/helpers/assistant-switches-mock")
|
||||
).mockAssistantSwitches(vi.fn().mockResolvedValue({ coach: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
|
||||
const actual =
|
||||
|
||||
@@ -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<typeof import("@/lib/feature-flags")>();
|
||||
return { ...actual, getAssistantFlags: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/feature-flags", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/feature-flags")>()),
|
||||
...(
|
||||
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", () => ({
|
||||
|
||||
@@ -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" })),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user