Files
fusion/packages/dashboard/app/hooks/__tests__/useFavorites.test.ts
Fusion 473b53d306 fix(FN-2573): prevent stale-closure writes in useFavorites
- Refactor useFavorites callbacks to read latest state instead of closing over stale favorites values
- Ensure add/remove/toggle flows update local state and persistence from a consistent source of truth
- Add regression tests that reproduce stale-closure timing scenarios and verify favorites remain correct
- Expand hook test coverage for rapid consecutive updates to guard against future closure regressions
2026-04-25 20:32:37 -07:00

165 lines
5.2 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useFavorites } from "../useFavorites";
import * as api from "../../api";
vi.mock("../../api", () => ({
fetchModels: vi.fn(),
updateGlobalSettings: vi.fn(),
}));
const mockFetchModels = vi.mocked(api.fetchModels);
const mockUpdateGlobalSettings = vi.mocked(api.updateGlobalSettings);
describe("useFavorites", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchModels.mockResolvedValue({
models: [
{
provider: "anthropic",
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
reasoning: true,
contextWindow: 200000,
},
],
favoriteProviders: ["openai"],
favoriteModels: ["gpt-4o"],
});
mockUpdateGlobalSettings.mockResolvedValue({} as never);
});
it("loads available models and favorites from API", async () => {
const { result } = renderHook(() => useFavorites());
await waitFor(() => {
expect(result.current.availableModels).toHaveLength(1);
expect(result.current.favoriteProviders).toEqual(["openai"]);
expect(result.current.favoriteModels).toEqual(["gpt-4o"]);
});
expect(mockFetchModels).toHaveBeenCalledTimes(1);
});
it("optimistically toggles provider favorite and persists settings", async () => {
const { result } = renderHook(() => useFavorites());
await waitFor(() => {
expect(result.current.favoriteProviders).toEqual(["openai"]);
});
await act(async () => {
await result.current.toggleFavoriteProvider("anthropic");
});
expect(result.current.favoriteProviders).toEqual(["anthropic", "openai"]);
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({
favoriteProviders: ["anthropic", "openai"],
favoriteModels: ["gpt-4o"],
});
});
it("rolls back provider favorite on update failure", async () => {
mockUpdateGlobalSettings.mockRejectedValueOnce(new Error("network"));
const { result } = renderHook(() => useFavorites());
await waitFor(() => {
expect(result.current.favoriteProviders).toEqual(["openai"]);
});
await act(async () => {
await expect(result.current.toggleFavoriteProvider("anthropic")).rejects.toThrow("network");
});
expect(result.current.favoriteProviders).toEqual(["openai"]);
});
it("rolls back model favorite on update failure", async () => {
mockUpdateGlobalSettings.mockRejectedValueOnce(new Error("network"));
const { result } = renderHook(() => useFavorites());
await waitFor(() => {
expect(result.current.favoriteModels).toEqual(["gpt-4o"]);
});
await act(async () => {
await expect(result.current.toggleFavoriteModel("claude-sonnet-4-5")).rejects.toThrow("network");
});
expect(result.current.favoriteModels).toEqual(["gpt-4o"]);
});
it("toggle model favorite preserves a prior provider favorite change", async () => {
const { result } = renderHook(() => useFavorites());
await waitFor(() => {
expect(result.current.favoriteProviders).toEqual(["openai"]);
expect(result.current.favoriteModels).toEqual(["gpt-4o"]);
});
await act(async () => {
await result.current.toggleFavoriteProvider("anthropic");
});
await act(async () => {
await result.current.toggleFavoriteModel("claude-sonnet-4-5");
});
expect(mockUpdateGlobalSettings).toHaveBeenNthCalledWith(2, {
favoriteProviders: ["anthropic", "openai"],
favoriteModels: ["claude-sonnet-4-5", "gpt-4o"],
});
});
it("toggle provider favorite preserves a prior model favorite change", async () => {
const { result } = renderHook(() => useFavorites());
await waitFor(() => {
expect(result.current.favoriteProviders).toEqual(["openai"]);
expect(result.current.favoriteModels).toEqual(["gpt-4o"]);
});
await act(async () => {
await result.current.toggleFavoriteModel("claude-sonnet-4-5");
});
await act(async () => {
await result.current.toggleFavoriteProvider("anthropic");
});
expect(mockUpdateGlobalSettings).toHaveBeenNthCalledWith(2, {
favoriteProviders: ["anthropic", "openai"],
favoriteModels: ["claude-sonnet-4-5", "gpt-4o"],
});
});
it("rapid toggles of both model and provider favorites persist correctly", async () => {
const { result } = renderHook(() => useFavorites());
await waitFor(() => {
expect(result.current.favoriteProviders).toEqual(["openai"]);
expect(result.current.favoriteModels).toEqual(["gpt-4o"]);
});
await act(async () => {
const toggleModelPromise = result.current.toggleFavoriteModel("claude-sonnet-4-5");
const toggleProviderPromise = result.current.toggleFavoriteProvider("anthropic");
await Promise.all([toggleModelPromise, toggleProviderPromise]);
});
expect(mockUpdateGlobalSettings).toHaveBeenNthCalledWith(1, {
favoriteProviders: ["openai"],
favoriteModels: ["claude-sonnet-4-5", "gpt-4o"],
});
expect(mockUpdateGlobalSettings).toHaveBeenNthCalledWith(2, {
favoriteProviders: ["anthropic", "openai"],
favoriteModels: ["claude-sonnet-4-5", "gpt-4o"],
});
});
});