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) <noreply@runfusion.ai>
This commit is contained in:
@@ -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<typeof vi.fn>).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 () => {
|
||||
|
||||
@@ -11,6 +11,74 @@ type AnyMock = Mock;
|
||||
|
||||
const fallbackFns = new Map<string, AnyMock>();
|
||||
|
||||
const DEFAULT_MODEL_REGISTRY_REFRESH_TIMEOUT_MS = 15_000;
|
||||
|
||||
type MockRefreshableModelRegistry = {
|
||||
refresh: () => unknown;
|
||||
modelRuntime?: {
|
||||
refresh: (options?: { allowNetwork?: boolean; signal?: AbortSignal; force?: boolean }) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
type MockModelRegistryRefreshOptions = {
|
||||
timeoutMs?: number;
|
||||
allowNetwork?: boolean;
|
||||
log?: (message: string) => void;
|
||||
};
|
||||
|
||||
type MockModelRegistryRefreshOutcome = "completed" | "timed_out" | "failed";
|
||||
|
||||
function boundMockModelRegistryRefresh(
|
||||
underlying: Promise<unknown>,
|
||||
options: Pick<MockModelRegistryRefreshOptions, "timeoutMs" | "log"> = {},
|
||||
controller?: AbortController,
|
||||
): Promise<MockModelRegistryRefreshOutcome> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_MODEL_REGISTRY_REFRESH_TIMEOUT_MS;
|
||||
let timedOut = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, 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<unknown>; bounded: Promise<MockModelRegistryRefreshOutcome> } {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user