diff --git a/tests/hooks/useProviderActions.test.tsx b/tests/hooks/useProviderActions.test.tsx
new file mode 100644
index 0000000..2e329ac
--- /dev/null
+++ b/tests/hooks/useProviderActions.test.tsx
@@ -0,0 +1,362 @@
+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();
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: (...args: unknown[]) => toastSuccessMock(...args),
+ error: (...args: unknown[]) => toastErrorMock(...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();
+
+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),
+ },
+}));
+
+interface WrapperProps {
+ children: ReactNode;
+}
+
+function createWrapper() {
+ const queryClient = new QueryClient();
+
+ const wrapper = ({ children }: WrapperProps) => (
+ {children}
+ );
+
+ return { wrapper, queryClient };
+}
+
+function createProvider(overrides: Partial = {}): 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();
+ toastSuccessMock.mockReset();
+ toastErrorMock.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;
+
+ 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);
+ 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("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("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("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("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);
+ });
+});