mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-03-22 15:08:22 +08:00
* feat(webdav): add robust auto sync with failure feedback (cherry picked from commit bb6760124a62a964b36902c004e173534910728f) * fix(webdav): enforce bounded download and extraction size (cherry picked from commit 7777d6ec2b9bba07c8bbba9b04fe3ea6b15e0e79) * fix(webdav): only show auto-sync callout for auto-source errors * refactor(webdav): remove services->commands auto-sync dependency
445 lines
13 KiB
TypeScript
445 lines
13 KiB
TypeScript
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import "@testing-library/jest-dom";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection";
|
|
import type { WebDavSyncSettings } from "@/types";
|
|
|
|
const toastSuccessMock = vi.fn();
|
|
const toastErrorMock = vi.fn();
|
|
const toastWarningMock = vi.fn();
|
|
const toastInfoMock = vi.fn();
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: {
|
|
success: (...args: unknown[]) => toastSuccessMock(...args),
|
|
error: (...args: unknown[]) => toastErrorMock(...args),
|
|
warning: (...args: unknown[]) => toastWarningMock(...args),
|
|
info: (...args: unknown[]) => toastInfoMock(...args),
|
|
},
|
|
}));
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string) => key,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/button", () => ({
|
|
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
|
}));
|
|
|
|
vi.mock("@/components/ui/input", () => ({
|
|
Input: (props: any) => <input {...props} />,
|
|
}));
|
|
|
|
vi.mock("@/components/ui/switch", () => ({
|
|
Switch: ({ checked, onCheckedChange, ...props }: any) => (
|
|
<button
|
|
role="switch"
|
|
aria-checked={checked}
|
|
onClick={() => onCheckedChange?.(!checked)}
|
|
{...props}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/select", () => ({
|
|
Select: ({ value, onValueChange, children }: any) => (
|
|
<select
|
|
data-testid="webdav-preset-select"
|
|
value={value}
|
|
onChange={(event) => onValueChange?.(event.target.value)}
|
|
>
|
|
{children}
|
|
</select>
|
|
),
|
|
SelectTrigger: ({ children }: any) => <>{children}</>,
|
|
SelectValue: () => null,
|
|
SelectContent: ({ children }: any) => <>{children}</>,
|
|
SelectItem: ({ value, children }: any) => (
|
|
<option value={value}>{children}</option>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/dialog", () => ({
|
|
Dialog: ({ open, children }: any) => (open ? <div>{children}</div> : null),
|
|
DialogContent: ({ children }: any) => <div>{children}</div>,
|
|
DialogDescription: ({ children }: any) => <div>{children}</div>,
|
|
DialogFooter: ({ children }: any) => <div>{children}</div>,
|
|
DialogHeader: ({ children }: any) => <div>{children}</div>,
|
|
DialogTitle: ({ children }: any) => <h2>{children}</h2>,
|
|
}));
|
|
|
|
const { settingsApiMock } = vi.hoisted(() => ({
|
|
settingsApiMock: {
|
|
webdavTestConnection: vi.fn(),
|
|
webdavSyncSaveSettings: vi.fn(),
|
|
webdavSyncFetchRemoteInfo: vi.fn(),
|
|
webdavSyncUpload: vi.fn(),
|
|
webdavSyncDownload: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/api", () => ({
|
|
settingsApi: settingsApiMock,
|
|
}));
|
|
|
|
const baseConfig: WebDavSyncSettings = {
|
|
enabled: true,
|
|
baseUrl: "https://dav.example.com/dav/",
|
|
username: "alice",
|
|
password: "secret",
|
|
remoteRoot: "cc-switch-sync",
|
|
profile: "default",
|
|
autoSync: false,
|
|
status: {},
|
|
};
|
|
|
|
function renderSection(config?: WebDavSyncSettings) {
|
|
const client = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
return render(
|
|
<QueryClientProvider client={client}>
|
|
<WebdavSyncSection config={config} />
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
describe("WebdavSyncSection", () => {
|
|
beforeEach(() => {
|
|
toastSuccessMock.mockReset();
|
|
toastErrorMock.mockReset();
|
|
toastWarningMock.mockReset();
|
|
toastInfoMock.mockReset();
|
|
settingsApiMock.webdavTestConnection.mockReset();
|
|
settingsApiMock.webdavSyncSaveSettings.mockReset();
|
|
settingsApiMock.webdavSyncFetchRemoteInfo.mockReset();
|
|
settingsApiMock.webdavSyncUpload.mockReset();
|
|
settingsApiMock.webdavSyncDownload.mockReset();
|
|
|
|
settingsApiMock.webdavSyncSaveSettings.mockResolvedValue({ success: true });
|
|
settingsApiMock.webdavTestConnection.mockResolvedValue({
|
|
success: true,
|
|
message: "ok",
|
|
});
|
|
settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValue({
|
|
deviceName: "My MacBook",
|
|
createdAt: "2026-02-01T10:00:00Z",
|
|
snapshotId: "snapshot-1",
|
|
version: 2,
|
|
compatible: true,
|
|
artifacts: ["db.sql", "skills.zip"],
|
|
});
|
|
settingsApiMock.webdavSyncUpload.mockResolvedValue({ status: "uploaded" });
|
|
settingsApiMock.webdavSyncDownload.mockResolvedValue({ status: "downloaded" });
|
|
});
|
|
|
|
it("shows auto sync error callout when last auto sync failed", () => {
|
|
renderSection({
|
|
...baseConfig,
|
|
status: {
|
|
lastError: "network timeout",
|
|
lastErrorSource: "auto",
|
|
},
|
|
});
|
|
|
|
expect(
|
|
screen.getByText("settings.webdavSync.autoSyncLastErrorTitle"),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByText("network timeout")).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not show auto sync error callout for manual sync errors", () => {
|
|
renderSection({
|
|
...baseConfig,
|
|
status: {
|
|
lastError: "manual upload failed",
|
|
lastErrorSource: "manual",
|
|
},
|
|
});
|
|
|
|
expect(
|
|
screen.queryByText("settings.webdavSync.autoSyncLastErrorTitle"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not show auto sync error callout when source is missing", () => {
|
|
renderSection({
|
|
...baseConfig,
|
|
autoSync: true,
|
|
status: {
|
|
lastError: "legacy error without source",
|
|
},
|
|
});
|
|
|
|
expect(
|
|
screen.queryByText("settings.webdavSync.autoSyncLastErrorTitle"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows validation error when saving without base url", async () => {
|
|
renderSection({ ...baseConfig, baseUrl: "" });
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
|
|
|
expect(toastErrorMock).toHaveBeenCalledWith("settings.webdavSync.missingUrl");
|
|
expect(settingsApiMock.webdavSyncSaveSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("saves settings and auto tests connection", async () => {
|
|
renderSection(baseConfig);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
baseUrl: "https://dav.example.com/dav/",
|
|
username: "alice",
|
|
password: "secret",
|
|
autoSync: false,
|
|
}),
|
|
false,
|
|
);
|
|
await waitFor(() => {
|
|
expect(settingsApiMock.webdavTestConnection).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
baseUrl: "https://dav.example.com/dav/",
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
expect(toastSuccessMock).toHaveBeenCalledWith(
|
|
"settings.webdavSync.saveAndTestSuccess",
|
|
);
|
|
});
|
|
|
|
it("saves auto sync as true after toggle", async () => {
|
|
renderSection(baseConfig);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("switch", { name: "settings.webdavSync.autoSync" }),
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
autoSync: true,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("blocks upload when there are unsaved changes", async () => {
|
|
renderSection(baseConfig);
|
|
|
|
fireEvent.change(
|
|
screen.getByPlaceholderText("settings.webdavSync.usernamePlaceholder"),
|
|
{ target: { value: "bob" } },
|
|
);
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "settings.webdavSync.upload" }),
|
|
);
|
|
|
|
expect(toastErrorMock).toHaveBeenCalledWith(
|
|
"settings.webdavSync.unsavedChanges",
|
|
);
|
|
expect(settingsApiMock.webdavSyncFetchRemoteInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("disables sync buttons until config is saved", () => {
|
|
renderSection(undefined);
|
|
|
|
const uploadButton = screen.getByRole("button", {
|
|
name: "settings.webdavSync.upload",
|
|
});
|
|
const downloadButton = screen.getByRole("button", {
|
|
name: "settings.webdavSync.download",
|
|
});
|
|
|
|
expect(uploadButton).toBeDisabled();
|
|
expect(downloadButton).toBeDisabled();
|
|
expect(settingsApiMock.webdavSyncFetchRemoteInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fetches remote info and uploads after confirmation", async () => {
|
|
renderSection(baseConfig);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "settings.webdavSync.upload" }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", {
|
|
name: "settings.webdavSync.confirmUpload.confirm",
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(settingsApiMock.webdavSyncUpload).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(toastSuccessMock).toHaveBeenCalledWith(
|
|
"settings.webdavSync.uploadSuccess",
|
|
);
|
|
});
|
|
|
|
it("blocks upload confirmation if form changes after dialog opens", async () => {
|
|
renderSection(baseConfig);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "settings.webdavSync.upload" }),
|
|
);
|
|
await waitFor(() => {
|
|
expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
fireEvent.change(screen.getByPlaceholderText("cc-switch-sync"), {
|
|
target: { value: "new-root" },
|
|
});
|
|
fireEvent.click(
|
|
screen.getByRole("button", {
|
|
name: "settings.webdavSync.confirmUpload.confirm",
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(toastErrorMock).toHaveBeenCalledWith(
|
|
"settings.webdavSync.unsavedChanges",
|
|
);
|
|
});
|
|
expect(settingsApiMock.webdavSyncUpload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fetches remote info and downloads after confirmation", async () => {
|
|
renderSection(baseConfig);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "settings.webdavSync.download" }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", {
|
|
name: "settings.webdavSync.confirmDownload.confirm",
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(settingsApiMock.webdavSyncDownload).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(toastSuccessMock).toHaveBeenCalledWith(
|
|
"settings.webdavSync.downloadSuccess",
|
|
);
|
|
});
|
|
|
|
it("blocks download confirmation if form changes after dialog opens", async () => {
|
|
renderSection(baseConfig);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "settings.webdavSync.download" }),
|
|
);
|
|
await waitFor(() => {
|
|
expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
fireEvent.change(screen.getByPlaceholderText("default"), {
|
|
target: { value: "new-profile" },
|
|
});
|
|
fireEvent.click(
|
|
screen.getByRole("button", {
|
|
name: "settings.webdavSync.confirmDownload.confirm",
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(toastErrorMock).toHaveBeenCalledWith(
|
|
"settings.webdavSync.unsavedChanges",
|
|
);
|
|
});
|
|
expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows info when no remote snapshot is found for download", async () => {
|
|
settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValueOnce({ empty: true });
|
|
renderSection(baseConfig);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "settings.webdavSync.download" }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(toastInfoMock).toHaveBeenCalledWith("settings.webdavSync.noRemoteData");
|
|
});
|
|
expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks download when remote snapshot is incompatible", async () => {
|
|
settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValueOnce({
|
|
deviceName: "Legacy Machine",
|
|
createdAt: "2025-01-01T00:00:00Z",
|
|
snapshotId: "legacy-snapshot",
|
|
version: 1,
|
|
compatible: false,
|
|
artifacts: ["db.sql"],
|
|
});
|
|
renderSection(baseConfig);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "settings.webdavSync.download" }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(toastErrorMock).toHaveBeenCalledWith(
|
|
"settings.webdavSync.incompatibleVersion",
|
|
);
|
|
});
|
|
expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled();
|
|
expect(
|
|
screen.queryByRole("button", {
|
|
name: "settings.webdavSync.confirmDownload.confirm",
|
|
}),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows error when download fails after confirmation", async () => {
|
|
settingsApiMock.webdavSyncDownload.mockRejectedValueOnce(new Error("boom"));
|
|
renderSection(baseConfig);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "settings.webdavSync.download" }),
|
|
);
|
|
await waitFor(() => {
|
|
expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", {
|
|
name: "settings.webdavSync.confirmDownload.confirm",
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(toastErrorMock).toHaveBeenCalledWith(
|
|
"settings.webdavSync.downloadFailed",
|
|
);
|
|
});
|
|
});
|
|
});
|