Implements the test cases called out in the PR #119 review threads (code-review, test-strategy report, red-team) — TESTS ONLY, no production code changes. packages/git-sync (vitest): - lib converter/markdown gaps: pageBreak data-loss (it.fails repro), subpages lossy round-trip, nested/fenced callouts, ol->taskList bridge, column.width number<->string drift, empty details. - engine units: parentFolderFile, planReconciliation swap/chained move, buildVaultLayout last-resort-by-id, firstDivergence, applyPushActions / applyPullActions failure isolation. - real temp-git integration: diffNameStatus -z rename+add/modify alignment, copy-line behavior, per-invocation committer identity (no leak into repo/global config). - ENFORCED type-level GitSyncClient contract via vitest typecheck over a *.test-d.ts file (tsconfig.vitest.json; build tsconfig untouched). apps/server (jest): - orchestrator: delete-cap neutralization + fail-safe, Redis lock / mutex skip ladder + release-on-throw, merge guard, pull/push order, remote template substitution, poll lifecycle. - page-change listener: loop-guard, debounce coalescing, id resolution, error swallowing. - vault registry, controller authz (trigger + status), env validation/getters, page.service git-sync provenance stamping, persistence precedence (agent > git-sync > user) + no boundary snapshot, space.service audit-delta, space.repo jsonb-merge, converter-gate corpus extension (mention/math/details/marks). apps/client (vitest + testing-library): - history-item git-sync badge: render gating + non-clickable. - edit-space-form toggle: initial state, optimistic payload, rollback on error, disabled states. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
5.2 KiB
TypeScript
172 lines
5.2 KiB
TypeScript
import {
|
|
describe,
|
|
it,
|
|
expect,
|
|
vi,
|
|
beforeAll,
|
|
afterEach,
|
|
} from "vitest";
|
|
import {
|
|
render,
|
|
screen,
|
|
cleanup,
|
|
fireEvent,
|
|
waitFor,
|
|
} from "@testing-library/react";
|
|
import { MantineProvider } from "@mantine/core";
|
|
|
|
// --- Mocks for the heavy / networked module graph ---------------------------
|
|
// EditSpaceForm wires the "Enable Git sync" Switch to a TanStack-Query mutation
|
|
// (useUpdateSpaceMutation). We mock ONLY that hook so the test fully controls
|
|
// mutateAsync (resolve / reject) and isPending, and stub i18n. The real Mantine
|
|
// Switch is rendered so the checkbox role / disabled state is meaningful.
|
|
|
|
// i18n: identity translator — labels stay as their English keys for queries.
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({ t: (key: string) => key }),
|
|
}));
|
|
|
|
// Mutation hook: a controllable mutateAsync plus a togglable isPending.
|
|
const mutateAsync = vi.fn();
|
|
let isPending = false;
|
|
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
|
useUpdateSpaceMutation: () => ({
|
|
mutateAsync,
|
|
get isPending() {
|
|
return isPending;
|
|
},
|
|
}),
|
|
}));
|
|
|
|
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
|
|
beforeAll(() => {
|
|
if (!window.matchMedia) {
|
|
window.matchMedia = (query: string) =>
|
|
({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addListener: () => {},
|
|
removeListener: () => {},
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
dispatchEvent: () => false,
|
|
}) as unknown as MediaQueryList;
|
|
}
|
|
});
|
|
|
|
import { EditSpaceForm } from "./edit-space-form";
|
|
import type { ISpace } from "@/features/space/types/space.types.ts";
|
|
|
|
function makeSpace(overrides: Partial<ISpace> = {}): ISpace {
|
|
return {
|
|
id: "space-1",
|
|
name: "Engineering",
|
|
description: "",
|
|
slug: "eng",
|
|
hostname: "host",
|
|
creatorId: "u1",
|
|
createdAt: new Date("2026-01-01"),
|
|
updatedAt: new Date("2026-01-01"),
|
|
...overrides,
|
|
} as ISpace;
|
|
}
|
|
|
|
function renderForm(props: { space: ISpace; readOnly?: boolean }) {
|
|
return render(
|
|
<MantineProvider>
|
|
<EditSpaceForm space={props.space} readOnly={props.readOnly} />
|
|
</MantineProvider>,
|
|
);
|
|
}
|
|
|
|
// The git-sync toggle is the only switch on the form. Mantine renders it as an
|
|
// <input type="checkbox" role="switch">; its label text lives in a sibling
|
|
// wrapper, so query by role and assert the visible label is present alongside.
|
|
function getToggle(): HTMLInputElement {
|
|
// Sanity: the human-readable label is rendered.
|
|
screen.getByText("Enable Git sync");
|
|
return screen.getByRole("switch") as HTMLInputElement;
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
mutateAsync.mockReset();
|
|
isPending = false;
|
|
});
|
|
|
|
describe("EditSpaceForm git-sync toggle", () => {
|
|
// Test 3: initial checked state derives from settings.gitSync.enabled ?? false.
|
|
it("derives initial checked state from space.settings.gitSync.enabled (true -> checked)", () => {
|
|
renderForm({
|
|
space: makeSpace({ settings: { gitSync: { enabled: true } } }),
|
|
});
|
|
expect(getToggle().checked).toBe(true);
|
|
});
|
|
|
|
it("defaults to unchecked when gitSync settings are missing", () => {
|
|
renderForm({ space: makeSpace() });
|
|
expect(getToggle().checked).toBe(false);
|
|
});
|
|
|
|
// Test 4: toggling fires the mutation with { spaceId, gitSyncEnabled } and
|
|
// optimistically flips the switch.
|
|
it("fires the mutation with the correct payload and optimistically flips on", async () => {
|
|
mutateAsync.mockResolvedValue(undefined);
|
|
renderForm({ space: makeSpace() });
|
|
|
|
const toggle = getToggle();
|
|
expect(toggle.checked).toBe(false);
|
|
|
|
fireEvent.click(toggle);
|
|
|
|
// Optimistic update: the switch reflects the new state immediately.
|
|
expect(toggle.checked).toBe(true);
|
|
|
|
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
|
expect(mutateAsync).toHaveBeenCalledWith({
|
|
spaceId: "space-1",
|
|
gitSyncEnabled: true,
|
|
});
|
|
|
|
// Resolution leaves the toggle on.
|
|
await waitFor(() => expect(toggle.checked).toBe(true));
|
|
});
|
|
|
|
// Test 5: rollback on mutation error — the most valuable test.
|
|
it("rolls back the toggle to its prior state when the mutation rejects", async () => {
|
|
mutateAsync.mockRejectedValue(new Error("network"));
|
|
renderForm({
|
|
space: makeSpace({ settings: { gitSync: { enabled: false } } }),
|
|
});
|
|
|
|
const toggle = getToggle();
|
|
expect(toggle.checked).toBe(false);
|
|
|
|
fireEvent.click(toggle);
|
|
|
|
// Optimistically flips on before the rejection lands.
|
|
expect(toggle.checked).toBe(true);
|
|
expect(mutateAsync).toHaveBeenCalledWith({
|
|
spaceId: "space-1",
|
|
gitSyncEnabled: true,
|
|
});
|
|
|
|
// After the rejected promise settles, the component reverts to OFF so the
|
|
// user is not misled into believing sync is enabled.
|
|
await waitFor(() => expect(toggle.checked).toBe(false));
|
|
});
|
|
|
|
// Test 6: disabled when readOnly and when the mutation is pending.
|
|
it("disables the toggle when readOnly", () => {
|
|
renderForm({ space: makeSpace(), readOnly: true });
|
|
expect(getToggle().disabled).toBe(true);
|
|
});
|
|
|
|
it("disables the toggle while the mutation is pending", () => {
|
|
isPending = true;
|
|
renderForm({ space: makeSpace() });
|
|
expect(getToggle().disabled).toBe(true);
|
|
});
|
|
});
|