mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'test/v139-flag-mocks' into release/v1.39.0
This commit is contained in:
commit
f893dae004
14 files changed
+359
-36
No files matched your search
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Every mock of `@/lib/feature-flags` answers the capability loader too.
|
||||
*
|
||||
* The module has two readers of the operator's assistant switches. The AI
|
||||
* capability loader (`src/lib/ai/capabilities/load.ts`) reads
|
||||
* `loadAssistantSwitches()`; the crons and a few routes read
|
||||
* `getAssistantFlags()`. A mock that stubs only the second leaves the first
|
||||
* undefined, the loader throws, catches its own throw, and resolves every
|
||||
* capability to `check_failed`. Nothing goes red. A positive control then
|
||||
* quietly exercises the "AI unavailable" branch, and a negative case passes
|
||||
* for reason `check_failed` rather than the reason it names. That shipped in
|
||||
* an integration test whose owner-side control enqueued nothing and whose
|
||||
* delegate-side refusal proved nothing, and eight unit files carried the same
|
||||
* shape.
|
||||
*
|
||||
* So a mock of the module must say what the loader reads: either through
|
||||
* `mockAssistantSwitches` (`./helpers/assistant-switches-mock.ts`), which
|
||||
* points both readers at one source, or by naming `loadAssistantSwitches` in
|
||||
* its factory. A spread of the real module does not count: the real reader
|
||||
* then runs against whatever the file did to `@/lib/db`, which is the same
|
||||
* failure one layer down. An automock (no factory) does not count either; its
|
||||
* `loadAssistantSwitches` returns `undefined`.
|
||||
*
|
||||
* Its limit: it reads the factory's text, not its behaviour. A factory that
|
||||
* names `loadAssistantSwitches` and returns nonsense from it passes here; the
|
||||
* assertion that it returns what the test means belongs in the test.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { stripComments, walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SELF = "src/__tests__/feature-flags-mock-guard.test.ts";
|
||||
|
||||
const ROOTS = [
|
||||
{ root: join(process.cwd(), "src"), label: "src", floor: 3000 },
|
||||
{ root: join(process.cwd(), "tests"), label: "tests", floor: 100 },
|
||||
];
|
||||
|
||||
/**
|
||||
* The start of a mock of the module: `vi.mock(` or `vi.doMock(`, the path in
|
||||
* any quote style or as `import("…")`, with any whitespace and line breaks
|
||||
* between the tokens. Prettier splits long calls across lines, so a matcher
|
||||
* that assumed one line would find nothing and pass.
|
||||
*/
|
||||
const MOCK_START =
|
||||
/\bvi\s*\.\s*(?:doMock|mock)\s*\(\s*(?:import\s*\(\s*)?(["'`])@\/lib\/feature-flags(?:\/index(?:\.ts)?)?\1/g;
|
||||
|
||||
/** What a factory has to contain for the loader's reader to be answered. */
|
||||
const ANSWERS_THE_LOADER =
|
||||
/\bmockAssistantSwitches\s*\(|\bloadAssistantSwitches\b/;
|
||||
|
||||
interface MockSite {
|
||||
file: string;
|
||||
line: number;
|
||||
/** The call's full argument text, from its `(` to the matching `)`. */
|
||||
call: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The argument text of the call that opens at `open` (the index of its `(`),
|
||||
* by bracket balance. Good enough for test factories; it skips over string
|
||||
* literals so a `)` inside one does not close the call early.
|
||||
*/
|
||||
function callText(source: string, open: number): string {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (let i = open; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (quote) {
|
||||
if (ch === "\\") i += 1;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
||||
else if (ch === "(") depth += 1;
|
||||
else if (ch === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(open + 1, i);
|
||||
}
|
||||
}
|
||||
throw new Error("unbalanced vi.mock call");
|
||||
}
|
||||
|
||||
function findFeatureFlagMocks(
|
||||
files: ReadonlyArray<{ file: string; source: string }>,
|
||||
): MockSite[] {
|
||||
const sites: MockSite[] = [];
|
||||
for (const { file, source } of files) {
|
||||
const code = stripComments(source);
|
||||
for (const match of code.matchAll(MOCK_START)) {
|
||||
const open = code.indexOf("(", match.index);
|
||||
sites.push({
|
||||
file,
|
||||
line: code.slice(0, match.index).split("\n").length,
|
||||
call: callText(code, open),
|
||||
});
|
||||
}
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
function repoFiles(): Array<{ file: string; source: string }> {
|
||||
return ROOTS.flatMap(({ root, label, floor }) =>
|
||||
walkSourceFiles(root, { floor })
|
||||
.filter((rel) => !rel.startsWith("generated/"))
|
||||
// This file plants offending mocks as string literals to test its own
|
||||
// matcher; the sweep reads everything else.
|
||||
.filter((rel) => `${label}/${rel}` !== SELF)
|
||||
.map((rel) => ({
|
||||
file: `${label}/${rel}`,
|
||||
source: readFileSync(join(root, rel), "utf8"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
describe("mocks of @/lib/feature-flags", () => {
|
||||
const sites = findFeatureFlagMocks(repoFiles());
|
||||
|
||||
it("finds the mocks it is meant to police", () => {
|
||||
// Pinned at the count on the day this was written. An empty set agrees
|
||||
// with every rule below, so a matcher that stopped matching would pass
|
||||
// them all; this is what fails instead.
|
||||
expect(sites.length).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it("every one answers the capability loader's reader", () => {
|
||||
const offenders = sites
|
||||
.filter((site) => !ANSWERS_THE_LOADER.test(site.call))
|
||||
.map((site) => `${site.file}:${site.line}`);
|
||||
expect(
|
||||
offenders,
|
||||
"These mocks leave `loadAssistantSwitches` unanswered, so the AI " +
|
||||
"capability loader throws and every capability reads `check_failed`. " +
|
||||
"Use `mockAssistantSwitches` from src/__tests__/helpers/" +
|
||||
"assistant-switches-mock.ts, or name `loadAssistantSwitches` in the " +
|
||||
"factory.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the matcher catches the shapes it claims to", () => {
|
||||
// The rule, run against planted sources rather than the tree, so a
|
||||
// matcher that went blind shows here and not only as a clean sweep.
|
||||
const planted = findFeatureFlagMocks([
|
||||
{
|
||||
file: "one-line.test.ts",
|
||||
source: `vi.mock("@/lib/feature-flags", () => ({ getAssistantFlags: vi.fn() }));`,
|
||||
},
|
||||
{
|
||||
file: "split.test.ts",
|
||||
source: `vi\n .mock(\n '@/lib/feature-flags',\n async (orig) => ({ ...(await orig()), getAssistantFlags: vi.fn() }),\n);`,
|
||||
},
|
||||
{ file: "automock.test.ts", source: `vi.mock("@/lib/feature-flags");` },
|
||||
{
|
||||
file: "do-mock.test.ts",
|
||||
source: `vi.doMock(import("@/lib/feature-flags"), () => ({}));`,
|
||||
},
|
||||
{
|
||||
file: "commented.test.ts",
|
||||
source: `// vi.mock("@/lib/feature-flags", () => ({}));`,
|
||||
},
|
||||
{
|
||||
file: "helper.test.ts",
|
||||
source: `vi.mock("@/lib/feature-flags", async () =>\n (await import("x")).mockAssistantSwitches(() => f()),\n);`,
|
||||
},
|
||||
{
|
||||
file: "named.test.ts",
|
||||
source: `vi.mock("@/lib/feature-flags", () => ({ loadAssistantSwitches: vi.fn(async () => null) }));`,
|
||||
},
|
||||
{
|
||||
file: "other-module.test.ts",
|
||||
source: `vi.mock("@/lib/feature-flags-extra", () => ({}));`,
|
||||
},
|
||||
]);
|
||||
const verdict = Object.fromEntries(
|
||||
planted.map((site) => [site.file, ANSWERS_THE_LOADER.test(site.call)]),
|
||||
);
|
||||
expect(verdict).toEqual({
|
||||
"one-line.test.ts": false,
|
||||
"split.test.ts": false,
|
||||
"automock.test.ts": false,
|
||||
"do-mock.test.ts": false,
|
||||
"helper.test.ts": true,
|
||||
"named.test.ts": true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -37,7 +37,10 @@ vi.mock("@/lib/idempotency", () => ({
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
// The status re-warm the shared post-mutation tail fires. Mocked at the
|
||||
// module the tail imports it from; a mock anywhere else is never called, and
|
||||
// the real one runs the AI capability loader against this file's stub db.
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
@@ -68,7 +71,7 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -149,6 +152,12 @@ describe("POST /api/measurements/bulk-delete", () => {
|
||||
|
||||
// 4 rows in, 3 distinct (type, day) recomputes out.
|
||||
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
|
||||
// And one status re-warm over the distinct types.
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => {
|
||||
@@ -163,6 +172,7 @@ describe("POST /api/measurements/bulk-delete", () => {
|
||||
expect(body.data.deleted).toBe(0);
|
||||
expect(invalidateUserMeasurements).not.toHaveBeenCalled();
|
||||
expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled();
|
||||
expect(invalidateStatusInsightsForTypes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a batch over the 200-id cap with 422", async () => {
|
||||
|
||||
@@ -28,7 +28,10 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
// The status re-warm the shared post-mutation tail fires. Mocked at the
|
||||
// module the tail imports it from; a mock anywhere else is never called, and
|
||||
// the real one runs the AI capability loader against this file's stub db.
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
@@ -57,7 +60,7 @@ import { DELETE } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -112,6 +115,12 @@ describe("DELETE /api/measurements/by-external-ids", () => {
|
||||
externalId: { in: ["uuid-aaa"] },
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
// The deleted row's type is re-warmed; the zero-row case below is the
|
||||
// twin that must not reach it.
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [
|
||||
"WEIGHT",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is a no-op for a foreign-origin externalId that collides on a non-app-minted row", async () => {
|
||||
|
||||
@@ -37,7 +37,10 @@ vi.mock("@/lib/idempotency", () => ({
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
// The status re-warm the shared post-mutation tail fires. Mocked at the
|
||||
// module the tail imports it from; a mock anywhere else is never called, and
|
||||
// the real one runs the AI capability loader against this file's stub db.
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
@@ -68,7 +71,7 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -144,6 +147,12 @@ describe("POST /api/measurements/restore", () => {
|
||||
|
||||
// 4 rows in, 3 distinct (type, day) recomputes out.
|
||||
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
|
||||
// And one status re-warm over the distinct types.
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is a no-op (restored: 0) when no id is owned + tombstoned — no rollup, no invalidate", async () => {
|
||||
@@ -158,6 +167,7 @@ describe("POST /api/measurements/restore", () => {
|
||||
expect(body.data.restored).toBe(0);
|
||||
expect(invalidateUserMeasurements).not.toHaveBeenCalled();
|
||||
expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled();
|
||||
expect(invalidateStatusInsightsForTypes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a batch over the 200-id cap with 422", async () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -69,6 +69,14 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProvider: vi.fn().mockResolvedValue({ type: "none" }),
|
||||
hasAnyConfiguredProvider: vi.fn().mockResolvedValue(false),
|
||||
// The AI capability loader's presence probe, answering the same "no
|
||||
// provider". Without it the loader throws and every capability on the
|
||||
// three wires reads `check_failed` instead of `no_provider`.
|
||||
probeProviderChain: vi.fn().mockResolvedValue({
|
||||
entries: [],
|
||||
localOcrEnabled: false,
|
||||
managedBy: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
@@ -236,9 +244,14 @@ async function readWires(): Promise<Wires> {
|
||||
data: {
|
||||
status: string;
|
||||
value: { configured?: boolean } | null;
|
||||
ai: { reason: string | null };
|
||||
} | null;
|
||||
};
|
||||
expect(derivedBody.data?.status).toBe("ok");
|
||||
// The AI state rides the same payload. `no_provider` is this fixture's
|
||||
// honest answer; `check_failed` would mean a mock starved the capability
|
||||
// loader and every wire above was read on its failure path.
|
||||
expect(derivedBody.data?.ai.reason).toBe("no_provider");
|
||||
|
||||
return {
|
||||
snapshot: snapshotBody.data!.healthScore!.configured,
|
||||
|
||||
Reference in new issue
Block a user