diff --git a/src/__tests__/feature-flags-mock-guard.test.ts b/src/__tests__/feature-flags-mock-guard.test.ts new file mode 100644 index 000000000..15d83e52d --- /dev/null +++ b/src/__tests__/feature-flags-mock-guard.test.ts @@ -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, + }); + }); +}); 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/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts index 9cba46767..32ff2f171 100644 --- a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/measurements/by-external-ids/__tests__/route.test.ts b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts index c618a6bc8..072608711 100644 --- a/src/app/api/measurements/by-external-ids/__tests__/route.test.ts +++ b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/measurements/restore/__tests__/route.test.ts b/src/app/api/measurements/restore/__tests__/route.test.ts index dbbc12592..dcd8a0f15 100644 --- a/src/app/api/measurements/restore/__tests__/route.test.ts +++ b/src/app/api/measurements/restore/__tests__/route.test.ts @@ -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 () => { 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"; diff --git a/tests/integration/health-score-configured-flag.test.ts b/tests/integration/health-score-configured-flag.test.ts index b36609a20..91a556b29 100644 --- a/tests/integration/health-score-configured-flag.test.ts +++ b/tests/integration/health-score-configured-flag.test.ts @@ -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 { 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,