From 38d128ca29287ee6b8abfd18c316cc81bd0ff526 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 15 Aug 2026 14:40:55 -0700 Subject: [PATCH] FN-9103: align auth route tests with model refresh caching Update dashboard auth route coverage and engine mocks for cached model registry refresh behavior. - provide faithful bounded model registry refresh helpers in the dashboard engine mock - reset refresh cache state between API key route tests - verify API key saves invalidate the cache without redundant refreshes Files changed: .../dashboard/src/__tests__/routes-auth.test.ts | 20 ++++-- packages/dashboard/src/test/mockCoreEngine.ts | 74 ++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-9103 Fusion-Task-Lineage: d09453b1-08a5-4cde-849c-31e22bd61c0f Co-authored-by: Fusion (runfusion.ai) --- .../src/__tests__/routes-auth.test.ts | 20 ++++- packages/dashboard/src/test/mockCoreEngine.ts | 74 +++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index e23d44bf7e..173c95a6c9 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -43,6 +43,7 @@ import * as updateCheckModule from "../update-check.js"; import { __setAgentReflectionServiceForTests } from "../routes/register-agent-reflection-rating-routes.js"; import { parseGitHubCopilotDeviceCode } from "../routes/register-auth-routes.js"; import { createAuthMiddleware } from "../auth-middleware.js"; +import { __resetModelRegistryRefreshCacheForTests } from "../model-registry-refresh-cache.js"; // Mock @fusion/core for gh CLI auth checks const mockCentralListProjects = vi.fn().mockResolvedValue([]); @@ -3491,6 +3492,7 @@ describe("POST /auth/api-key", () => { beforeEach(() => { store = createMockStore(); authStorage = createMockAuthStorage(); + __resetModelRegistryRefreshCacheForTests(); }); function buildApp(options?: { @@ -3640,24 +3642,34 @@ describe("POST /auth/api-key", () => { expect(res.body.error).toContain("not supported"); }); - it("runs post-save refresh hook and model registry refresh for opencode-go", async () => { + it("runs post-save refresh hook and invalidates the model registry cache for opencode-go", async () => { const onApiKeySaved = vi.fn().mockResolvedValue({ registeredCount: 3, reason: "no-models-from-cli" }); - const modelRegistry = { refresh: vi.fn(), getAvailable: vi.fn().mockReturnValue([]) } as unknown as ModelRegistryLike; + const modelRegistry = { + refresh: vi.fn(async () => undefined), + getAvailable: vi.fn().mockReturnValue([]), + } as unknown as ModelRegistryLike; (authStorage.getApiKeyProviders as ReturnType).mockReturnValue([ { id: "openrouter", name: "OpenRouter" }, { id: "opencode-go", name: "Opencode (Go)" }, ]); + const app = buildApp({ onApiKeySaved, modelRegistry }); - const res = await REQUEST(buildApp({ onApiKeySaved, modelRegistry }), "POST", "/api/auth/api-key", JSON.stringify({ + await GET(app, "/api/models"); + expect(modelRegistry.refresh).toHaveBeenCalledOnce(); + + const res = await REQUEST(app, "POST", "/api/auth/api-key", JSON.stringify({ provider: "opencode-go", apiKey: "sk-test", }), { "Content-Type": "application/json" }); expect(res.status).toBe(200); expect(onApiKeySaved).toHaveBeenCalledWith("opencode-go"); - expect(modelRegistry.refresh).toHaveBeenCalled(); + expect(modelRegistry.refresh).toHaveBeenCalledOnce(); expect(res.body.modelsRefreshed).toBe(3); expect(res.body.refreshReason).toBe("no-models-from-cli"); + + await GET(app, "/api/models"); + expect(modelRegistry.refresh).toHaveBeenCalledTimes(2); }); it("returns success when post-save refresh hook throws", async () => { diff --git a/packages/dashboard/src/test/mockCoreEngine.ts b/packages/dashboard/src/test/mockCoreEngine.ts index 4c8502d794..6f07239d52 100644 --- a/packages/dashboard/src/test/mockCoreEngine.ts +++ b/packages/dashboard/src/test/mockCoreEngine.ts @@ -11,6 +11,74 @@ type AnyMock = Mock; const fallbackFns = new Map(); +const DEFAULT_MODEL_REGISTRY_REFRESH_TIMEOUT_MS = 15_000; + +type MockRefreshableModelRegistry = { + refresh: () => unknown; + modelRuntime?: { + refresh: (options?: { allowNetwork?: boolean; signal?: AbortSignal; force?: boolean }) => Promise; + }; +}; + +type MockModelRegistryRefreshOptions = { + timeoutMs?: number; + allowNetwork?: boolean; + log?: (message: string) => void; +}; + +type MockModelRegistryRefreshOutcome = "completed" | "timed_out" | "failed"; + +function boundMockModelRegistryRefresh( + underlying: Promise, + options: Pick = {}, + controller?: AbortController, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_MODEL_REGISTRY_REFRESH_TIMEOUT_MS; + let timedOut = false; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + timedOut = true; + controller?.abort(); + reject(new Error(`Model registry refresh timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + return Promise.race([underlying, timeout]) + .then(() => "completed" as const) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + if (timedOut || controller?.signal.aborted || /timed out/i.test(message)) { + options.log?.(`Model registry refresh timed out after ${timeoutMs}ms; continuing with cached models`); + return "timed_out" as const; + } + options.log?.(`Model registry refresh failed: ${message}`); + return "failed" as const; + }) + .finally(() => { + if (timer) clearTimeout(timer); + }); +} + +/* +FNXC:ModelCatalog 2026-08-15-21:23: +Dashboard route tests wholesale-mock @fusion/engine, but FN-8902's request cache needs real +refresh starters and bounders. Keep this light mock faithful so GET /models invokes the registry +instead of treating fallback vi.fn() output as a failed refresh. +*/ +function startMockModelRegistryRefresh( + modelRegistry: MockRefreshableModelRegistry, + options: MockModelRegistryRefreshOptions = {}, +): { underlying: Promise; bounded: Promise } { + const controller = new AbortController(); + const runtime = modelRegistry.modelRuntime; + const underlying = typeof runtime?.refresh === "function" + ? Promise.resolve().then(() => runtime.refresh({ allowNetwork: options.allowNetwork ?? true, signal: controller.signal })) + : Promise.resolve().then(() => modelRegistry.refresh()); + void underlying.catch(() => {}); + return { underlying, bounded: boundMockModelRegistryRefresh(underlying, options, controller) }; +} + function getFallback(name: string): AnyMock { if (!fallbackFns.has(name)) fallbackFns.set(name, vi.fn()); return fallbackFns.get(name)!; @@ -47,6 +115,12 @@ export function createEngineMock(overrides: AnyModule = {}): AnyModule { return withFallbackFunctions(actual, { createFnAgent: vi.fn(), promptWithFallback: vi.fn(), + DEFAULT_MODEL_REGISTRY_REFRESH_TIMEOUT_MS, + startFusionModelRegistryRefresh: startMockModelRegistryRefresh, + boundExistingModelRegistryRefresh: boundMockModelRegistryRefresh, + refreshFusionModelRegistry: (modelRegistry: MockRefreshableModelRegistry, options: MockModelRegistryRefreshOptions = {}) => ( + startMockModelRegistryRefresh(modelRegistry, options).bounded + ), /* FNXC:TestSkills 2026-06-17-19:33: Dashboard route tests mock @fusion/engine wholesale, so skill-aware planning lanes need a shaped session-skill helper result instead of the fallback vi.fn() returning undefined.