Files
CC-Switch/tests/hooks/useSettings.test.tsx
T
Jason 2ebe34810c refactor(hooks): introduce unified post-change sync utility
- Add postChangeSync.ts utility with Result pattern for graceful error handling
- Replace try-catch with syncCurrentProvidersLiveSafe in useImportExport
- Add directory-change-triggered sync in useSettings to maintain SSOT
- Introduce partial-success status to distinguish import success from sync failures
- Add test coverage for sync behavior in different scenarios

This refactoring ensures config.json changes are reliably synced to live
files while providing better user feedback for edge cases.
2025-11-01 23:58:29 +08:00

325 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useSettings } from "@/hooks/useSettings";
import type { Settings } from "@/types";
const mutateAsyncMock = vi.fn();
const useSettingsQueryMock = vi.fn();
const setAppConfigDirOverrideMock = vi.fn();
const applyClaudePluginConfigMock = vi.fn();
const syncCurrentProvidersLiveMock = vi.fn();
const toastErrorMock = vi.fn();
const toastSuccessMock = vi.fn();
let settingsFormMock: any;
let directorySettingsMock: any;
let metadataMock: any;
let serverSettings: Settings;
vi.mock("sonner", () => ({
toast: {
error: (...args: unknown[]) => toastErrorMock(...args),
success: (...args: unknown[]) => toastSuccessMock(...args),
},
}));
vi.mock("@/hooks/useSettingsForm", () => ({
useSettingsForm: () => settingsFormMock,
}));
vi.mock("@/hooks/useDirectorySettings", () => ({
useDirectorySettings: () => directorySettingsMock,
}));
vi.mock("@/hooks/useSettingsMetadata", () => ({
useSettingsMetadata: () => metadataMock,
}));
vi.mock("@/lib/query", () => ({
useSettingsQuery: (...args: unknown[]) => useSettingsQueryMock(...args),
useSaveSettingsMutation: () => ({
mutateAsync: mutateAsyncMock,
isPending: false,
}),
}));
vi.mock("@/lib/api", () => ({
settingsApi: {
setAppConfigDirOverride: (...args: unknown[]) =>
setAppConfigDirOverrideMock(...args),
applyClaudePluginConfig: (...args: unknown[]) =>
applyClaudePluginConfigMock(...args),
syncCurrentProvidersLive: (...args: unknown[]) =>
syncCurrentProvidersLiveMock(...args),
},
}));
const createSettingsFormMock = (overrides: Record<string, unknown> = {}) => ({
settings: {
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
claudeConfigDir: "/claude",
codexConfigDir: "/codex",
language: "zh",
},
isLoading: false,
initialLanguage: "zh",
updateSettings: vi.fn(),
resetSettings: vi.fn(),
syncLanguage: vi.fn(),
...overrides,
});
const createDirectorySettingsMock = (overrides: Record<string, unknown> = {}) => ({
appConfigDir: undefined,
resolvedDirs: {
appConfig: "/home/mock/.cc-switch",
claude: "/default/claude",
codex: "/default/codex",
},
isLoading: false,
initialAppConfigDir: undefined,
updateDirectory: vi.fn(),
updateAppConfigDir: vi.fn(),
browseDirectory: vi.fn(),
browseAppConfigDir: vi.fn(),
resetDirectory: vi.fn(),
resetAppConfigDir: vi.fn(),
resetAllDirectories: vi.fn(),
...overrides,
});
const createMetadataMock = (overrides: Record<string, unknown> = {}) => ({
isPortable: false,
requiresRestart: false,
isLoading: false,
acknowledgeRestart: vi.fn(),
setRequiresRestart: vi.fn(),
...overrides,
});
describe("useSettings hook", () => {
beforeEach(() => {
mutateAsyncMock.mockReset();
useSettingsQueryMock.mockReset();
setAppConfigDirOverrideMock.mockReset();
applyClaudePluginConfigMock.mockReset();
syncCurrentProvidersLiveMock.mockReset();
toastErrorMock.mockReset();
toastSuccessMock.mockReset();
window.localStorage.clear();
serverSettings = {
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
claudeConfigDir: "/server/claude",
codexConfigDir: "/server/codex",
language: "zh",
};
useSettingsQueryMock.mockReturnValue({
data: serverSettings,
isLoading: false,
});
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
language: "zh",
},
});
directorySettingsMock = createDirectorySettingsMock();
metadataMock = createMetadataMock();
mutateAsyncMock.mockResolvedValue(true);
setAppConfigDirOverrideMock.mockResolvedValue(true);
applyClaudePluginConfigMock.mockResolvedValue(true);
});
it("saves settings and flags restart when app config directory changes", async () => {
serverSettings = {
...serverSettings,
enableClaudePluginIntegration: true,
claudeConfigDir: "/server/claude",
codexConfigDir: undefined,
language: "en",
};
useSettingsQueryMock.mockReturnValue({
data: serverSettings,
isLoading: false,
});
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
claudeConfigDir: " /custom/claude ",
codexConfigDir: " ",
language: "en",
enableClaudePluginIntegration: true,
},
initialLanguage: "en",
});
directorySettingsMock = createDirectorySettingsMock({
appConfigDir: " /override/app ",
initialAppConfigDir: "/previous/app",
});
const { result } = renderHook(() => useSettings());
let saveResult: { requiresRestart: boolean } | null = null;
await act(async () => {
saveResult = await result.current.saveSettings();
});
expect(saveResult).toEqual({ requiresRestart: true });
expect(mutateAsyncMock).toHaveBeenCalledTimes(1);
const payload = mutateAsyncMock.mock.calls[0][0] as Settings;
expect(payload.claudeConfigDir).toBe("/custom/claude");
expect(payload.codexConfigDir).toBeUndefined();
expect(payload.language).toBe("en");
expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith("/override/app");
expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: false });
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true);
expect(window.localStorage.getItem("language")).toBe("en");
expect(toastErrorMock).not.toHaveBeenCalled();
// live
expect(syncCurrentProvidersLiveMock).toHaveBeenCalledTimes(1);
});
it("saves settings without restart when directory unchanged", async () => {
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
enableClaudePluginIntegration: false,
language: "zh",
},
initialLanguage: "zh",
});
directorySettingsMock = createDirectorySettingsMock({
appConfigDir: undefined,
initialAppConfigDir: undefined,
});
const { result } = renderHook(() => useSettings());
let saveResult: { requiresRestart: boolean } | null = null;
await act(async () => {
saveResult = await result.current.saveSettings();
});
expect(saveResult).toEqual({ requiresRestart: false });
expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith(null);
expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: true });
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
// 目录未变化,不应触发同步
expect(syncCurrentProvidersLiveMock).not.toHaveBeenCalled();
});
it("shows toast when Claude plugin sync fails but continues flow", async () => {
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
enableClaudePluginIntegration: true,
language: "zh",
},
});
directorySettingsMock = createDirectorySettingsMock({
appConfigDir: "/override/app",
initialAppConfigDir: "/prior/app",
});
applyClaudePluginConfigMock.mockRejectedValueOnce(new Error("sync failed"));
const { result } = renderHook(() => useSettings());
await act(async () => {
await result.current.saveSettings();
});
expect(toastErrorMock).toHaveBeenCalled();
const message = toastErrorMock.mock.calls.at(-1)?.[0] as string;
expect(message).toContain("同步 Claude 插件失败");
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true);
});
it("resets form, language and directories using server data", () => {
serverSettings = {
...serverSettings,
claudeConfigDir: " /server/claude ",
codexConfigDir: " ",
language: "zh",
};
useSettingsQueryMock.mockReturnValue({
data: serverSettings,
isLoading: false,
});
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
language: "zh",
},
initialLanguage: "zh",
});
directorySettingsMock = createDirectorySettingsMock();
const { result } = renderHook(() => useSettings());
act(() => {
result.current.resetSettings();
});
expect(settingsFormMock.resetSettings).toHaveBeenCalledWith(serverSettings);
expect(settingsFormMock.syncLanguage).toHaveBeenCalledWith(
settingsFormMock.initialLanguage,
);
expect(directorySettingsMock.resetAllDirectories).toHaveBeenCalledWith(
"/server/claude",
undefined,
);
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
});
it("returns null immediately when settings state is missing", async () => {
settingsFormMock = createSettingsFormMock({
settings: null,
});
const { result } = renderHook(() => useSettings());
let resultValue: { requiresRestart: boolean } | null = null;
await act(async () => {
resultValue = await result.current.saveSettings();
});
expect(resultValue).toBeNull();
expect(mutateAsyncMock).not.toHaveBeenCalled();
expect(setAppConfigDirOverrideMock).not.toHaveBeenCalled();
});
it("throws when save mutation rejects and keeps restart flag untouched", async () => {
settingsFormMock = createSettingsFormMock();
directorySettingsMock = createDirectorySettingsMock({
appConfigDir: "/override/app",
initialAppConfigDir: "/override/app",
});
const rejection = new Error("save failed");
mutateAsyncMock.mockRejectedValueOnce(rejection);
const { result } = renderHook(() => useSettings());
await expect(
act(async () => {
await result.current.saveSettings();
}),
).rejects.toThrow("save failed");
expect(setAppConfigDirOverrideMock).not.toHaveBeenCalled();
expect(metadataMock.setRequiresRestart).not.toHaveBeenCalledWith(true);
});
});