Files
cc-switch/tests/hooks/useUpdateProviderMutation.test.tsx
Jason 63ed5002e5 feat(claude): switch Claude Code by replacing only key fields
Switching, adding the first provider, editing the current provider,
sync, universal providers and restoring after the local router all
write settings.json through the write engine now. Only endpoint,
credential, model and protocol fields plus the provider's own switches
are replaced; hooks, plugins, permissions and everything else stay
byte-for-byte. The file write and the current-provider pointer commit
as one operation, so a crash in between rolls forward on startup.

- No more backfill, no common-config snippet and no context-window
  injection for Claude. Window values left behind by the old injection
  are cleaned up from a frozen list.
- The provider editor shows what settings.json will look like after
  switching. On save, provider fields go back to the row and other
  edits go to live as global settings, with a three-way check against
  external changes and a dialog to resolve conflicts. Row fields that
  never reach live are listed with a one-click add.
- New Claude rows keep commonConfigEnabled: true so older versions that
  sync the row still merge their snippet.
- Presets drop timeout and telemetry keys; a test now requires Claude
  presets to contain only key or provider-owned fields.
2026-09-27 22:38:25 +08:00

156 lines
4.1 KiB
TypeScript

import type { ReactNode } from "react";
import { act, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useUpdateProviderMutation } from "@/lib/query/mutations";
import { usageKeys } from "@/lib/query/usage";
import type { Provider } from "@/types";
const apiMocks = vi.hoisted(() => ({
update: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
providersApi: {
update: (...args: unknown[]) => apiMocks.update(...args),
},
sessionsApi: {},
settingsApi: {},
}));
vi.mock("@/hooks/useHermes", () => ({
invalidateHermesProviderCaches: vi.fn(),
}));
vi.mock("@/hooks/useOpenClaw", () => ({
openclawKeys: {
health: ["openclaw", "health"],
},
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (_key: string, options?: { defaultValue?: string }) =>
options?.defaultValue ?? _key,
}),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return { wrapper, invalidateSpy };
}
function createProvider(overrides: Partial<Provider> = {}): Provider {
return {
id: "provider-1",
name: "Test Provider",
settingsConfig: {},
...overrides,
};
}
beforeEach(() => {
apiMocks.update.mockReset().mockResolvedValue(true);
});
describe("useUpdateProviderMutation", () => {
it("invalidates the updated provider usage query", async () => {
const { wrapper, invalidateSpy } = createWrapper();
const provider = createProvider({ id: "provider-b" });
const { result } = renderHook(() => useUpdateProviderMutation("codex"), {
wrapper,
});
await act(async () => {
await result.current.mutateAsync({ provider });
});
expect(apiMocks.update).toHaveBeenCalledWith(
provider,
"codex",
undefined,
undefined,
);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["providers", "codex"],
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: usageKeys.script("provider-b", "codex"),
});
expect(invalidateSpy).not.toHaveBeenCalledWith({
queryKey: usageKeys.all,
});
});
it("also invalidates the previous usage query when provider id changes", async () => {
const { wrapper, invalidateSpy } = createWrapper();
const provider = createProvider({ id: "provider-new" });
const { result } = renderHook(() => useUpdateProviderMutation("openclaw"), {
wrapper,
});
await act(async () => {
await result.current.mutateAsync({
provider,
originalId: "provider-old",
});
});
expect(apiMocks.update).toHaveBeenCalledWith(
provider,
"openclaw",
"provider-old",
undefined,
);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: usageKeys.script("provider-new", "openclaw"),
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: usageKeys.script("provider-old", "openclaw"),
});
expect(invalidateSpy).not.toHaveBeenCalledWith({
queryKey: usageKeys.all,
});
});
it("refreshes Pi provider caches even when an update fails", async () => {
apiMocks.update.mockRejectedValueOnce(new Error("conflict"));
const { wrapper, invalidateSpy } = createWrapper();
const provider = createProvider({ id: "pi-provider" });
const { result } = renderHook(() => useUpdateProviderMutation("pi"), {
wrapper,
});
await act(async () => {
await expect(result.current.mutateAsync({ provider })).rejects.toThrow(
"conflict",
);
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["pi", "currentState"],
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["providers", "pi"],
});
});
});