Files
cc-switch/tests/hooks/useProviderActions.test.tsx
T
Jason 8d38f0ee4f fix: allow provider switch without proxy, show warning instead of blocking
Remove the hard block that prevented switching to providers requiring
proxy (OpenAI format, Copilot, full URL mode) when the proxy is not
running. Now the switch proceeds with a warning toast. Also deduplicate
the proxy hint info toast so it doesn't appear alongside the warning.
2026-04-05 09:39:51 +08:00

563 lines
17 KiB
TypeScript

import type { ReactNode } from "react";
import { renderHook, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useProviderActions } from "@/hooks/useProviderActions";
import type { Provider, UsageScript } from "@/types";
const toastSuccessMock = vi.fn();
const toastErrorMock = vi.fn();
const toastInfoMock = vi.fn();
const toastWarningMock = vi.fn();
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => toastSuccessMock(...args),
error: (...args: unknown[]) => toastErrorMock(...args),
info: (...args: unknown[]) => toastInfoMock(...args),
warning: (...args: unknown[]) => toastWarningMock(...args),
},
}));
const addProviderMutateAsync = vi.fn();
const updateProviderMutateAsync = vi.fn();
const deleteProviderMutateAsync = vi.fn();
const switchProviderMutateAsync = vi.fn();
const addProviderMutation = {
mutateAsync: addProviderMutateAsync,
isPending: false,
};
const updateProviderMutation = {
mutateAsync: updateProviderMutateAsync,
isPending: false,
};
const deleteProviderMutation = {
mutateAsync: deleteProviderMutateAsync,
isPending: false,
};
const switchProviderMutation = {
mutateAsync: switchProviderMutateAsync,
isPending: false,
};
const useAddProviderMutationMock = vi.fn(() => addProviderMutation);
const useUpdateProviderMutationMock = vi.fn(() => updateProviderMutation);
const useDeleteProviderMutationMock = vi.fn(() => deleteProviderMutation);
const useSwitchProviderMutationMock = vi.fn(() => switchProviderMutation);
vi.mock("@/lib/query", () => ({
useAddProviderMutation: () => useAddProviderMutationMock(),
useUpdateProviderMutation: () => useUpdateProviderMutationMock(),
useDeleteProviderMutation: () => useDeleteProviderMutationMock(),
useSwitchProviderMutation: () => useSwitchProviderMutationMock(),
}));
const providersApiUpdateMock = vi.fn();
const providersApiUpdateTrayMenuMock = vi.fn();
const settingsApiGetMock = vi.fn();
const settingsApiApplyMock = vi.fn();
const openclawApiGetModelCatalogMock = vi.fn();
const openclawApiGetDefaultModelMock = vi.fn();
const openclawApiSetDefaultModelMock = vi.fn();
vi.mock("@/lib/api", () => ({
providersApi: {
update: (...args: unknown[]) => providersApiUpdateMock(...args),
updateTrayMenu: (...args: unknown[]) =>
providersApiUpdateTrayMenuMock(...args),
},
settingsApi: {
get: (...args: unknown[]) => settingsApiGetMock(...args),
applyClaudePluginConfig: (...args: unknown[]) =>
settingsApiApplyMock(...args),
},
openclawApi: {
getModelCatalog: (...args: unknown[]) =>
openclawApiGetModelCatalogMock(...args),
getDefaultModel: (...args: unknown[]) =>
openclawApiGetDefaultModelMock(...args),
setDefaultModel: (...args: unknown[]) =>
openclawApiSetDefaultModelMock(...args),
},
}));
interface WrapperProps {
children: ReactNode;
}
function createWrapper() {
const queryClient = new QueryClient();
const wrapper = ({ children }: WrapperProps) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return { wrapper, queryClient };
}
function createProvider(overrides: Partial<Provider> = {}): Provider {
return {
id: "provider-1",
name: "Test Provider",
settingsConfig: {},
category: "official",
...overrides,
};
}
beforeEach(() => {
addProviderMutateAsync.mockReset();
updateProviderMutateAsync.mockReset();
deleteProviderMutateAsync.mockReset();
switchProviderMutateAsync.mockReset();
providersApiUpdateMock.mockReset();
providersApiUpdateTrayMenuMock.mockReset();
settingsApiGetMock.mockReset();
settingsApiApplyMock.mockReset();
openclawApiGetModelCatalogMock.mockReset();
openclawApiGetDefaultModelMock.mockReset();
openclawApiSetDefaultModelMock.mockReset();
toastSuccessMock.mockReset();
toastErrorMock.mockReset();
toastInfoMock.mockReset();
toastWarningMock.mockReset();
addProviderMutation.isPending = false;
updateProviderMutation.isPending = false;
deleteProviderMutation.isPending = false;
switchProviderMutation.isPending = false;
useAddProviderMutationMock.mockClear();
useUpdateProviderMutationMock.mockClear();
useDeleteProviderMutationMock.mockClear();
useSwitchProviderMutationMock.mockClear();
});
describe("useProviderActions", () => {
it("should trigger mutation when calling addProvider", async () => {
addProviderMutateAsync.mockResolvedValueOnce(undefined);
const { wrapper } = createWrapper();
const providerInput = {
name: "New Provider",
settingsConfig: { token: "abc" },
} as Omit<Provider, "id">;
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await act(async () => {
await result.current.addProvider(providerInput);
});
expect(addProviderMutateAsync).toHaveBeenCalledTimes(1);
expect(addProviderMutateAsync).toHaveBeenCalledWith(providerInput);
});
it("should update tray menu when calling updateProvider", async () => {
updateProviderMutateAsync.mockResolvedValueOnce(undefined);
providersApiUpdateTrayMenuMock.mockResolvedValueOnce(true);
const { wrapper } = createWrapper();
const provider = createProvider();
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await act(async () => {
await result.current.updateProvider(provider);
});
expect(updateProviderMutateAsync).toHaveBeenCalledWith({
provider,
originalId: undefined,
});
expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1);
});
it("should not request plugin sync when switching non-Claude provider", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
const { wrapper } = createWrapper();
const provider = createProvider({ category: "custom" });
const { result } = renderHook(() => useProviderActions("codex"), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(provider);
});
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
expect(settingsApiGetMock).not.toHaveBeenCalled();
expect(settingsApiApplyMock).not.toHaveBeenCalled();
});
it("warns but still switches providers that require proxy when proxy is not running", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
const { wrapper } = createWrapper();
const provider = createProvider({
category: "custom",
meta: {
apiFormat: "openai_chat",
},
});
const { result } = renderHook(() => useProviderActions("claude", false), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(provider);
});
expect(toastWarningMock).toHaveBeenCalledTimes(1);
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
});
it("warns but still switches Codex full URL providers when proxy is not running", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
const { wrapper } = createWrapper();
const provider = createProvider({
category: "custom",
meta: {
isFullUrl: true,
},
});
const { result } = renderHook(() => useProviderActions("codex", false), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(provider);
});
expect(toastWarningMock).toHaveBeenCalledTimes(1);
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
});
it("should sync plugin config when switching Claude provider with integration enabled", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
settingsApiGetMock.mockResolvedValueOnce({
enableClaudePluginIntegration: true,
});
settingsApiApplyMock.mockResolvedValueOnce(true);
const { wrapper } = createWrapper();
const provider = createProvider({ category: "official" });
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(provider);
});
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
expect(settingsApiGetMock).toHaveBeenCalledTimes(1);
expect(settingsApiApplyMock).toHaveBeenCalledWith({ official: true });
});
it("should not call applyClaudePluginConfig when integration is disabled", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
settingsApiGetMock.mockResolvedValueOnce({
enableClaudePluginIntegration: false,
});
const { wrapper } = createWrapper();
const provider = createProvider();
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(provider);
});
expect(settingsApiGetMock).toHaveBeenCalledTimes(1);
expect(settingsApiApplyMock).not.toHaveBeenCalled();
});
it("should show error toast when plugin sync fails with error message", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
settingsApiGetMock.mockResolvedValueOnce({
enableClaudePluginIntegration: true,
});
settingsApiApplyMock.mockRejectedValueOnce(new Error("Sync failed"));
const { wrapper } = createWrapper();
const provider = createProvider();
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(provider);
});
expect(toastErrorMock).toHaveBeenCalledTimes(1);
expect(toastErrorMock.mock.calls[0]?.[0]).toBe("Sync failed");
});
it("propagates updateProvider errors", async () => {
updateProviderMutateAsync.mockRejectedValueOnce(new Error("update failed"));
const { wrapper } = createWrapper();
const provider = createProvider();
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await expect(
act(async () => {
await result.current.updateProvider(provider);
}),
).rejects.toThrow("update failed");
});
it("should use default error message when plugin sync fails without error message", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
settingsApiGetMock.mockResolvedValueOnce({
enableClaudePluginIntegration: true,
});
settingsApiApplyMock.mockRejectedValueOnce(new Error(""));
const { wrapper } = createWrapper();
const provider = createProvider();
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(provider);
});
expect(toastErrorMock).toHaveBeenCalledTimes(1);
expect(toastErrorMock.mock.calls[0]?.[0]).toBe("同步 Claude 插件失败");
});
it("handles mutation errors when plugin sync is skipped", async () => {
switchProviderMutateAsync.mockRejectedValueOnce(new Error("switch failed"));
const { wrapper } = createWrapper();
const provider = createProvider();
const { result } = renderHook(() => useProviderActions("codex"), {
wrapper,
});
await expect(
result.current.switchProvider(provider),
).resolves.toBeUndefined();
expect(settingsApiGetMock).not.toHaveBeenCalled();
expect(settingsApiApplyMock).not.toHaveBeenCalled();
});
it("should call delete mutation when calling deleteProvider", async () => {
deleteProviderMutateAsync.mockResolvedValueOnce(undefined);
const { wrapper } = createWrapper();
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await act(async () => {
await result.current.deleteProvider("provider-2");
});
expect(deleteProviderMutateAsync).toHaveBeenCalledWith("provider-2");
});
it("should update provider and refresh cache when saveUsageScript succeeds", async () => {
providersApiUpdateMock.mockResolvedValueOnce(true);
const { wrapper, queryClient } = createWrapper();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const provider = createProvider({
meta: {
usage_script: {
enabled: false,
language: "javascript",
code: "",
},
},
});
const script: UsageScript = {
enabled: true,
language: "javascript",
code: "return { success: true };",
timeout: 5,
};
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await act(async () => {
await result.current.saveUsageScript(provider, script);
});
expect(providersApiUpdateMock).toHaveBeenCalledWith(
{
...provider,
meta: {
...provider.meta,
usage_script: script,
},
},
"claude",
);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["providers", "claude"],
});
expect(toastSuccessMock).toHaveBeenCalledTimes(1);
});
it("should show error toast when saveUsageScript fails with error message", async () => {
providersApiUpdateMock.mockRejectedValueOnce(new Error("Save failed"));
const { wrapper } = createWrapper();
const provider = createProvider();
const script: UsageScript = {
enabled: true,
language: "javascript",
code: "return {}",
};
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await act(async () => {
await result.current.saveUsageScript(provider, script);
});
expect(toastErrorMock).toHaveBeenCalledTimes(1);
expect(toastErrorMock.mock.calls[0]?.[0]).toBe("Save failed");
});
it("should use default error message when saveUsageScript fails without error message", async () => {
providersApiUpdateMock.mockRejectedValueOnce(new Error(""));
const { wrapper } = createWrapper();
const provider = createProvider();
const script: UsageScript = {
enabled: true,
language: "javascript",
code: "return {}",
};
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await act(async () => {
await result.current.saveUsageScript(provider, script);
});
expect(toastErrorMock).toHaveBeenCalledTimes(1);
expect(toastErrorMock.mock.calls[0]?.[0]).toBe("用量查询配置保存失败");
});
it("propagates addProvider errors to caller", async () => {
addProviderMutateAsync.mockRejectedValueOnce(new Error("add failed"));
const { wrapper } = createWrapper();
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await expect(
act(async () => {
await result.current.addProvider({
name: "temp",
settingsConfig: {},
} as Omit<Provider, "id">);
}),
).rejects.toThrow("add failed");
});
it("propagates deleteProvider errors to caller", async () => {
deleteProviderMutateAsync.mockRejectedValueOnce(new Error("delete failed"));
const { wrapper } = createWrapper();
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await expect(
act(async () => {
await result.current.deleteProvider("provider-2");
}),
).rejects.toThrow("delete failed");
});
it("handles switch mutation errors silently", async () => {
switchProviderMutateAsync.mockRejectedValueOnce(new Error("switch failed"));
const { wrapper } = createWrapper();
const provider = createProvider();
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
await result.current.switchProvider(provider);
expect(settingsApiGetMock).not.toHaveBeenCalled();
expect(settingsApiApplyMock).not.toHaveBeenCalled();
});
it("should track pending state of all mutations in isLoading", () => {
addProviderMutation.isPending = true;
const { wrapper } = createWrapper();
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
expect(result.current.isLoading).toBe(true);
});
it("does not show backup details when setting OpenClaw default model", async () => {
openclawApiSetDefaultModelMock.mockResolvedValueOnce({
backupPath: "/tmp/openclaw-backup.json5",
warnings: [],
});
const { wrapper } = createWrapper();
const provider = createProvider({
settingsConfig: {
models: [{ id: "gpt-4.1" }, { id: "gpt-4.1-mini" }],
},
});
const { result } = renderHook(() => useProviderActions("openclaw"), {
wrapper,
});
await act(async () => {
await result.current.setAsDefaultModel(provider);
});
expect(openclawApiSetDefaultModelMock).toHaveBeenCalledWith({
primary: "provider-1/gpt-4.1",
fallbacks: ["provider-1/gpt-4.1-mini"],
});
expect(toastSuccessMock).toHaveBeenCalledTimes(1);
expect(toastSuccessMock.mock.calls[0]?.[1]).toEqual({ closeButton: true });
});
});
it("clears loading flag when all mutations idle", () => {
addProviderMutation.isPending = false;
updateProviderMutation.isPending = false;
deleteProviderMutation.isPending = false;
switchProviderMutation.isPending = false;
const { wrapper } = createWrapper();
const { result } = renderHook(() => useProviderActions("claude"), {
wrapper,
});
expect(result.current.isLoading).toBe(false);
});