import { render, screen, fireEvent, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { I18nKey } from "#/i18n/declaration";
import type { GitSyncStatus } from "#/types/git-sync";
import { GitSyncConfigForm } from "./git-sync-config-form";
const mutate = vi.fn();
const checkConfig = vi.fn();
const syncNow = vi.fn();
vi.mock("#/hooks/query/use-git-sync", () => ({
useUpdateGitSyncConfig: () => ({ mutate, isPending: false }),
useCheckGitSyncConfig: () => ({
mutateAsync: checkConfig,
isPending: false,
}),
}));
/** Submitting is async now: it awaits the reachability check before saving. */
const clickSave = async () => {
await act(async () => {
fireEvent.click(screen.getByTestId("git-sync-save-button"));
});
};
beforeEach(() => {
// Reachable unless a test says otherwise, so the save path stays the
// default one under test.
checkConfig.mockResolvedValue({
ok: true,
branch_exists: true,
detail: null,
});
});
const displayErrorToast = vi.fn();
const displaySuccessToast = vi.fn();
vi.mock("#/utils/custom-toast-handlers", () => ({
displayErrorToast: (message: string) => displayErrorToast(message),
displaySuccessToast: (message: string) => displaySuccessToast(message),
}));
/** Make the next save resolve down the success or the failure path. */
const respondWith = (outcome: "success" | { error: unknown }) => {
mutate.mockImplementation((_body, options) => {
if (outcome === "success") options.onSuccess?.();
else options.onError?.(outcome.error);
// react-query runs onSettled on both paths.
options.onSettled?.();
});
};
afterEach(() => {
vi.clearAllMocks();
});
const baseStatus: GitSyncStatus = {
enabled: true,
repo_url: "https://example.com/org/repo.git",
branch: "main",
path: "automations",
encryption_enabled: false,
interval_seconds: 0,
last_synced_commit: "abc1234",
last_synced_at: "2026-08-10T00:00:00Z",
last_error: null,
last_error_at: null,
dirty_count: 0,
};
describe("GitSyncConfigForm", () => {
it("always renders the token and encryption key fields empty", async () => {
render(
,
);
expect(screen.getByTestId("git-sync-token-input")).toHaveValue("");
expect(screen.getByTestId("git-sync-encryption-key-input")).toHaveValue("");
});
it("shows the encryption key placeholder based on encryption_enabled", async () => {
const { rerender } = render(
,
);
expect(screen.getByTestId("git-sync-encryption-key-input")).toHaveAttribute(
"placeholder",
I18nKey.AUTOMATIONS$GIT_SYNC$KEY_UNSET_PLACEHOLDER,
);
rerender(
,
);
expect(screen.getByTestId("git-sync-encryption-key-input")).toHaveAttribute(
"placeholder",
I18nKey.AUTOMATIONS$GIT_SYNC$KEY_SET_PLACEHOLDER,
);
});
it("enables submit once a field changes and sends only the changed field", async () => {
render(
,
);
expect(screen.getByTestId("git-sync-save-button")).toBeDisabled();
fireEvent.change(screen.getByTestId("git-sync-branch-input"), {
target: { value: "develop" },
});
expect(screen.getByTestId("git-sync-save-button")).not.toBeDisabled();
await clickSave();
expect(mutate).toHaveBeenCalledTimes(1);
expect(mutate.mock.calls[0][0]).toEqual({ branch: "develop" });
});
it("sends a typed token as a plain string, omitting unrelated fields", async () => {
render(
,
);
fireEvent.change(screen.getByTestId("git-sync-token-input"), {
target: { value: "ghp_new_token" },
});
await clickSave();
expect(mutate.mock.calls[0][0]).toEqual({ token: "ghp_new_token" });
});
it("shows the configured interval and sends a changed one", async () => {
render(
,
);
expect(screen.getByTestId("git-sync-interval-input")).toHaveValue(300);
fireEvent.change(screen.getByTestId("git-sync-interval-input"), {
target: { value: "60" },
});
await clickSave();
expect(mutate.mock.calls[0][0]).toEqual({ interval_seconds: 60 });
});
it("treats a blank interval as manual-only rather than no change", async () => {
// Clearing the field must mean 0 (manual), never "leave the timer on".
render(
,
);
fireEvent.change(screen.getByTestId("git-sync-interval-input"), {
target: { value: "" },
});
await clickSave();
expect(mutate.mock.calls[0][0]).toEqual({ interval_seconds: 0 });
});
it("clearing the token disables the text field and sends null", async () => {
render(
,
);
fireEvent.click(screen.getByTestId("git-sync-clear-token-switch"));
expect(screen.getByTestId("git-sync-token-input")).toBeDisabled();
expect(screen.getByTestId("git-sync-save-button")).not.toBeDisabled();
await clickSave();
expect(mutate.mock.calls[0][0]).toEqual({ token: null });
});
// Regression: these posted "" rather than null, and the backend stored the
// empty string as a literal override. An empty branch or path then made the
// next git command fatal (`git checkout -B ""`, `git add -A -- ""`), wedging
// every subsequent sync cycle.
it.each([
["git-sync-branch-input", "branch"],
["git-sync-path-input", "path"],
["git-sync-repo-url-input", "repo_url"],
])("clearing %s sends null, not an empty string", async (testId, field) => {
render(
,
);
fireEvent.change(screen.getByTestId(testId), { target: { value: "" } });
await clickSave();
expect(mutate.mock.calls[0][0]).toEqual({ [field]: null });
});
it("sends a whitespace-only field as null", async () => {
render(
,
);
fireEvent.change(screen.getByTestId("git-sync-path-input"), {
target: { value: " " },
});
await clickSave();
expect(mutate.mock.calls[0][0]).toEqual({ path: null });
});
// Regression: the clear switch remounts the secret input, which emptied the
// DOM field but left the typed value in state -- so a change of mind sent
// `token: ""`, an override that fails every later push with
// `fatal: Authentication failed`.
it.each([
["git-sync-token-input", "git-sync-clear-token-switch"],
["git-sync-encryption-key-input", "git-sync-clear-encryption-key-switch"],
])(
"forgets a typed %s when its clear switch is toggled on and back off",
async (inputTestId, switchTestId) => {
render(
,
);
fireEvent.change(screen.getByTestId(inputTestId), {
target: { value: "s3cret" },
});
fireEvent.click(screen.getByTestId(switchTestId));
fireEvent.click(screen.getByTestId(switchTestId));
// Nothing left to save: the field is empty again and no secret is
// pending, so an empty override can't be submitted.
expect(screen.getByTestId(inputTestId)).toHaveValue("");
expect(screen.getByTestId("git-sync-save-button")).toBeDisabled();
fireEvent.change(screen.getByTestId("git-sync-branch-input"), {
target: { value: "develop" },
});
await clickSave();
expect(mutate.mock.calls[0][0]).toEqual({ branch: "develop" });
},
);
it("keeps the edits and the enabled Save button when the save fails", async () => {
// React resets an uncontrolled `