Files
fusion/packages/dashboard/app/hooks/__tests__/useModelsCache.test.ts
gsxdsm 9c2a264322 FN-7398: restore Anthropic model selection
Restore Anthropic model selector discovery across API-key, OAuth subscription, and Claude CLI flows.

- Read injected auth storage when building /api/models so Settings connection state feeds model availability.
- Keep Anthropic subscription OAuth separate from direct Anthropic API-key rows while allowing enabled Claude CLI models to appear.
- Add regression coverage for selector rendering, stale empty catalog refresh, and authenticated model-route filtering.
- Document the restored Anthropic provider surfaces and add a patch changeset.

Files changed:
 .changeset/fn-7398-anthropic-model-selector.md     |   7 ++
 docs/settings-reference.md                         |   4 +-
 .../components/__tests__/ModelSelectorTab.test.tsx | 105 +++++++++++++++++
 .../app/hooks/__tests__/useModelsCache.test.ts     |  40 +++++++
 .../dashboard/src/__tests__/routes-auth.test.ts    | 125 ++++++++++++++++++++-
 .../dashboard/src/routes/register-model-routes.ts  |  60 +++++++++-
 6 files changed, 333 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-7398

Fusion-Task-Lineage: 5d1b1b8e-9b8b-40fe-b76f-7d6142e0e21f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-01 15:39:34 -07:00

170 lines
5.4 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
import { useModelsCache } from "../useModelsCache";
vi.mock("../../api", () => ({
fetchModels: vi.fn(),
}));
const { fetchModels } = await import("../../api");
const mockFetchModels = vi.mocked(fetchModels);
describe("useModelsCache", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockFetchModels.mockResolvedValue({
models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }],
favoriteProviders: ["openai"],
favoriteModels: ["gpt-4o"],
defaultProvider: "openai",
defaultModelId: "gpt-4o",
});
});
it("hydrates synchronously from cache", async () => {
localStorage.setItem(
SWR_CACHE_KEYS.MODELS,
JSON.stringify({
savedAt: Date.now(),
data: {
models: [{ provider: "anthropic", id: "claude", name: "Claude" }],
favoriteProviders: ["anthropic"],
favoriteModels: ["claude"],
defaultProvider: "anthropic",
defaultModelId: "claude",
},
}),
);
const { result } = renderHook(() => useModelsCache());
expect(result.current.loading).toBe(false);
expect(result.current.models[0]?.id).toBe("claude");
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalledTimes(1);
});
});
it("loads on cache miss and writes through", async () => {
const { result } = renderHook(() => useModelsCache());
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.models[0]?.id).toBe("gpt-4o");
});
const cached = JSON.parse(localStorage.getItem(SWR_CACHE_KEYS.MODELS) ?? "null") as { data: { models: Array<{ id: string }> } };
expect(cached.data.models[0]?.id).toBe("gpt-4o");
});
it("replaces a stale empty cached catalog for all mounted consumers", async () => {
localStorage.setItem(
SWR_CACHE_KEYS.MODELS,
JSON.stringify({
savedAt: Date.now(),
data: {
models: [],
favoriteProviders: [],
favoriteModels: [],
},
}),
);
mockFetchModels.mockResolvedValueOnce({
models: [{ provider: "pi-claude-cli", id: "claude-sonnet-5", name: "Claude Sonnet 5 (CLI)" }],
favoriteProviders: [],
favoriteModels: [],
});
const hookA = renderHook(() => useModelsCache());
const hookB = renderHook(() => useModelsCache());
expect(hookA.result.current.loading).toBe(false);
expect(hookA.result.current.models).toEqual([]);
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalledTimes(1);
expect(hookA.result.current.models).toEqual([
expect.objectContaining({ provider: "pi-claude-cli", id: "claude-sonnet-5" }),
]);
expect(hookB.result.current.models).toEqual([
expect.objectContaining({ provider: "pi-claude-cli", id: "claude-sonnet-5" }),
]);
});
const cached = JSON.parse(localStorage.getItem(SWR_CACHE_KEYS.MODELS) ?? "null") as { data: { models: Array<{ provider: string; id: string }> } };
expect(cached.data.models).toEqual([
expect.objectContaining({ provider: "pi-claude-cli", id: "claude-sonnet-5" }),
]);
});
it("deduplicates concurrent mounts", async () => {
let resolveFetch: ((value: Awaited<ReturnType<typeof fetchModels>>) => void) | undefined;
mockFetchModels.mockImplementationOnce(() => new Promise((resolve) => {
resolveFetch = resolve;
}));
const hookA = renderHook(() => useModelsCache());
const hookB = renderHook(() => useModelsCache());
expect(mockFetchModels).toHaveBeenCalledTimes(1);
await act(async () => {
resolveFetch?.({
models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }],
favoriteProviders: ["openai"],
favoriteModels: ["gpt-4o"],
defaultProvider: "openai",
defaultModelId: "gpt-4o",
});
});
await waitFor(() => {
expect(hookA.result.current.loading).toBe(false);
expect(hookB.result.current.loading).toBe(false);
expect(hookB.result.current.models[0]?.id).toBe("gpt-4o");
});
});
it("clears cache on failure without cached data", async () => {
const swrCacheModule = await import("../../utils/swrCache");
const clearCacheSpy = vi.spyOn(swrCacheModule, "clearCache");
mockFetchModels.mockRejectedValueOnce(new Error("nope"));
const { result } = renderHook(() => useModelsCache());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(clearCacheSpy).toHaveBeenCalledWith(SWR_CACHE_KEYS.MODELS);
});
it("refresh forces a new request", async () => {
const { result } = renderHook(() => useModelsCache());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
mockFetchModels.mockResolvedValueOnce({
models: [{ provider: "anthropic", id: "claude", name: "Claude" }],
favoriteProviders: ["anthropic"],
favoriteModels: ["claude"],
defaultProvider: "anthropic",
defaultModelId: "claude",
});
await act(async () => {
await result.current.refresh();
});
expect(mockFetchModels).toHaveBeenCalledTimes(2);
expect(result.current.models[0]?.id).toBe("claude");
});
});