test(git-sync): add reviewer-requested coverage across engine, server, client
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>
This commit is contained in:
committed by
claude code agent 227
parent
eb0aa12c83
commit
ba15fde809
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, cleanup, within } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Mantine Tooltip mounts its label lazily on hover via Floating UI, which is
|
||||
// flaky under jsdom. Replace ONLY the Tooltip with a thin wrapper that renders
|
||||
// the label inline (keeping Badge/Switch/etc. real), so the provenance label —
|
||||
// the contract we care about — is deterministically queryable.
|
||||
vi.mock("@mantine/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@mantine/core")>("@mantine/core");
|
||||
const Tooltip = ({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
{children}
|
||||
<span data-testid="tooltip-label">{label}</span>
|
||||
</>
|
||||
);
|
||||
Tooltip.Group = ({ children }: { children?: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
);
|
||||
return { ...actual, Tooltip };
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// HistoryItem pulls in i18n, jotai atoms (ai-chat / history), a config-backed
|
||||
// avatar and a time formatter. The provenance-badge contract is the unit under
|
||||
// test, so we stub everything else down to inert, deterministic renders and
|
||||
// keep the real Mantine Badge/Tooltip so role/label queries are meaningful.
|
||||
|
||||
// i18n: interpolate {{name}} so the git-sync tooltip carries the author name,
|
||||
// letting us assert provenance attribution without a real i18n backend.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars && typeof vars.name !== "undefined"
|
||||
? key.replace("{{name}}", String(vars.name))
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// jotai setters: the badges call useSetAtom; return inert setters so a click on
|
||||
// the (deep-linkable) AiAgentBadge would fire these — proving the git-sync badge
|
||||
// does NOT wire any of them.
|
||||
const setAiChatWindowOpen = vi.fn();
|
||||
const setActiveChatId = vi.fn();
|
||||
const setDraft = vi.fn();
|
||||
const setHistoryModalOpen = vi.fn();
|
||||
vi.mock("jotai", async () => {
|
||||
const actual = await vi.importActual<typeof import("jotai")>("jotai");
|
||||
return {
|
||||
...actual,
|
||||
useSetAtom: (atom: unknown) => {
|
||||
switch (atom) {
|
||||
case aiChatWindowOpenAtom:
|
||||
return setAiChatWindowOpen;
|
||||
case activeAiChatIdAtom:
|
||||
return setActiveChatId;
|
||||
case aiChatDraftAtom:
|
||||
return setDraft;
|
||||
case historyAtoms:
|
||||
return setHistoryModalOpen;
|
||||
default:
|
||||
return vi.fn();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Atoms are imported only as identity tokens for the useSetAtom switch above.
|
||||
vi.mock("@/features/ai-chat/atoms/ai-chat-atom.ts", () => ({
|
||||
activeAiChatIdAtom: { __tag: "activeAiChatIdAtom" },
|
||||
aiChatWindowOpenAtom: { __tag: "aiChatWindowOpenAtom" },
|
||||
aiChatDraftAtom: { __tag: "aiChatDraftAtom" },
|
||||
}));
|
||||
vi.mock("@/features/page-history/atoms/history-atoms.ts", () => ({
|
||||
historyAtoms: { __tag: "historyAtoms" },
|
||||
}));
|
||||
|
||||
// Avatar reaches into config (getAvatarUrl) — stub to a plain element.
|
||||
vi.mock("@/components/ui/custom-avatar.tsx", () => ({
|
||||
CustomAvatar: ({ name }: { name?: string }) => (
|
||||
<span data-testid="avatar">{name}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Deterministic, locale-free date string.
|
||||
vi.mock("@/lib/time", () => ({
|
||||
formattedDate: () => "2026-06-21",
|
||||
}));
|
||||
|
||||
import HistoryItem from "./history-item";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import type { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
|
||||
function makeItem(overrides: Partial<IPageHistory> = {}): IPageHistory {
|
||||
return {
|
||||
id: "h1",
|
||||
pageId: "p1",
|
||||
title: "Title",
|
||||
slug: "slug",
|
||||
icon: "",
|
||||
coverPhoto: "",
|
||||
version: 1,
|
||||
lastUpdatedById: "u1",
|
||||
workspaceId: "w1",
|
||||
createdAt: "2026-06-21T00:00:00.000Z",
|
||||
updatedAt: "2026-06-21T00:00:00.000Z",
|
||||
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderItem(item: IPageHistory) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<HistoryItem
|
||||
historyItem={item}
|
||||
index={0}
|
||||
onSelect={vi.fn()}
|
||||
isActive={false}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("HistoryItem git-sync provenance badge", () => {
|
||||
// Test 1: the git-sync badge renders ONLY for lastUpdatedSource === 'git-sync'.
|
||||
it("renders the Git sync badge only when lastUpdatedSource is 'git-sync'", () => {
|
||||
renderItem(makeItem({ lastUpdatedSource: "git-sync" }));
|
||||
expect(screen.getByText("Git sync")).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["agent", "agent"],
|
||||
["user", "user"],
|
||||
["undefined", undefined],
|
||||
])(
|
||||
"does NOT render the Git sync badge when lastUpdatedSource is %s",
|
||||
(_label, source) => {
|
||||
renderItem(makeItem({ lastUpdatedSource: source }));
|
||||
expect(screen.queryByText("Git sync")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
// Test 2: provenance attribution + the git-sync badge is NOT interactive.
|
||||
it("attributes the git-sync provenance to the correct author and is not clickable", () => {
|
||||
renderItem(
|
||||
makeItem({
|
||||
lastUpdatedSource: "git-sync",
|
||||
lastUpdatedBy: { id: "u2", name: "Bob", avatarUrl: "" },
|
||||
}),
|
||||
);
|
||||
|
||||
const badge = screen.getByText("Git sync");
|
||||
|
||||
// Provenance attribution: the tooltip label carries the author name (the
|
||||
// git-sync badge passes authorName -> "Synced from Git on behalf of {{name}}").
|
||||
expect(screen.getByText("Synced from Git on behalf of Bob")).toBeTruthy();
|
||||
|
||||
// The git-sync badge must NOT behave like AiAgentBadge: the badge element
|
||||
// itself is not a button, carries no role=button and no tabIndex, and
|
||||
// clicking it must not trigger any ai-chat deep-link. (The surrounding
|
||||
// history-row IS an UnstyledButton — that is the row's own select affordance,
|
||||
// not the badge — so we scope these checks to the badge element.)
|
||||
const badgeRoot = (badge.closest("[class*='mantine-Badge-root']") ??
|
||||
badge) as HTMLElement;
|
||||
expect(badgeRoot.getAttribute("role")).not.toBe("button");
|
||||
expect(badgeRoot.getAttribute("tabindex")).toBeNull();
|
||||
expect(badgeRoot.tagName.toLowerCase()).not.toBe("button");
|
||||
// No interactive descendant button lives inside the badge itself.
|
||||
expect(within(badgeRoot).queryByRole("button")).toBeNull();
|
||||
|
||||
badgeRoot.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||
expect(setAiChatWindowOpen).not.toHaveBeenCalled();
|
||||
expect(setDraft).not.toHaveBeenCalled();
|
||||
expect(setHistoryModalOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Sanity contrast: the agent badge (the copy-paste source) IS interactive when
|
||||
// it carries an aiChatId — proving the not-clickable assertion above is real.
|
||||
it("contrast: the AI-agent badge is a deep-link button when it has an aiChatId", () => {
|
||||
renderItem(
|
||||
makeItem({
|
||||
lastUpdatedSource: "agent",
|
||||
lastUpdatedAiChatId: "chat-1",
|
||||
}),
|
||||
);
|
||||
const agentBadge = screen.getByText("AI-agent");
|
||||
const root = agentBadge.closest("[role='button']");
|
||||
expect(root).not.toBeNull();
|
||||
within(root as HTMLElement).getByText("AI-agent");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
// Stub collaboration.util so importing the extension does not drag in the
|
||||
// editor-ext -> @tiptap/react -> react-dom graph (unloadable under jest's node
|
||||
// env, same coupling the gitmost-datasource / mcp specs document). The
|
||||
// extension only calls getPageId, jsonToText and isEmptyParagraphDoc from it on
|
||||
// the store path; tiptapExtensions is unused by onStoreDocument.
|
||||
jest.mock('../collaboration.util', () => ({
|
||||
tiptapExtensions: [],
|
||||
getPageId: (name: string) => name.replace(/^page\./, ''),
|
||||
jsonToText: () => 'text',
|
||||
isEmptyParagraphDoc: () => false,
|
||||
// The post-write mention extraction walks the doc via jsonToNode().descendants;
|
||||
// return a node-like stub with no descendants so no mentions are produced
|
||||
// (mention handling is out of scope here — we only assert provenance).
|
||||
jsonToNode: () => ({ descendants: () => undefined }),
|
||||
}));
|
||||
|
||||
// Control the Yjs<->JSON bridge: fromYdoc returns the "incoming" doc the writer
|
||||
// is storing. We keep it distinct from the page's persisted content so the
|
||||
// no-op guard (isDeepStrictEqual) never short-circuits the write.
|
||||
const INCOMING_JSON = { type: 'doc', content: [{ type: 'paragraph' }, { t: 1 }] };
|
||||
jest.mock('@hocuspocus/transformer', () => ({
|
||||
TiptapTransformer: {
|
||||
fromYdoc: jest.fn(() => INCOMING_JSON),
|
||||
toYdoc: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Run the executeTx callback inline with a passthrough trx.
|
||||
jest.mock('@docmost/db/utils', () => ({
|
||||
executeTx: jest.fn(async (_db: any, cb: any) => cb({} as any)),
|
||||
}));
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { PersistenceExtension } from './persistence.extension';
|
||||
import {
|
||||
onChangePayload,
|
||||
onStoreDocumentPayload,
|
||||
} from '@hocuspocus/server';
|
||||
|
||||
/**
|
||||
* Provenance-precedence coverage for PersistenceExtension.onStoreDocument
|
||||
* (test-strategy Module 4 / item #2): the contract `agent > git-sync > user`,
|
||||
* plus the negative that a git-sync store does NOT pin a boundary history
|
||||
* snapshot. We drive the precedence through the real public method (onChange to
|
||||
* arm the sticky agent marker, then onStoreDocument), mocking the repos / db /
|
||||
* Yjs bridge so no real database or collab server is needed. The store's
|
||||
* persisted `lastUpdatedSource` and the saveHistory call are the observable
|
||||
* outputs.
|
||||
*/
|
||||
describe('PersistenceExtension.onStoreDocument — provenance precedence (#2)', () => {
|
||||
const DOCUMENT_NAME = 'page.page-1';
|
||||
const PAGE_ID = 'page-1';
|
||||
|
||||
// `page.content` differs from INCOMING_JSON so the write is never skipped.
|
||||
const persistedPage = (overrides?: { lastUpdatedSource?: string }) => ({
|
||||
id: PAGE_ID,
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
creatorId: 'creator-1',
|
||||
contributorIds: ['creator-1'],
|
||||
content: { type: 'doc', content: [{ type: 'paragraph', content: [] }] },
|
||||
lastUpdatedSource: overrides?.lastUpdatedSource ?? 'user',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const build = (pageOverrides?: { lastUpdatedSource?: string }) => {
|
||||
const pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue(persistedPage(pageOverrides)),
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
};
|
||||
const pageHistoryRepo = {
|
||||
// No prior snapshot -> humanBaselineMissing is true, so the ONLY thing
|
||||
// gating the boundary snapshot in these tests is the source precedence.
|
||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||
saveHistory: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const collabHistory = {
|
||||
addContributors: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const transclusionService = {
|
||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
{} as any, // db
|
||||
aiQueue as any,
|
||||
historyQueue as any,
|
||||
notificationQueue as any,
|
||||
collabHistory as any,
|
||||
transclusionService as any,
|
||||
);
|
||||
|
||||
return { ext, pageRepo, pageHistoryRepo, historyQueue };
|
||||
};
|
||||
|
||||
// A real Y.Doc is required for Y.encodeStateAsUpdate(document); broadcastStateless
|
||||
// is a no-op spy. The fromYdoc bridge is mocked, so the doc's contents are
|
||||
// irrelevant to the JSON path.
|
||||
const makeStorePayload = (context: any): onStoreDocumentPayload =>
|
||||
({
|
||||
documentName: DOCUMENT_NAME,
|
||||
document: Object.assign(new Y.Doc(), {
|
||||
broadcastStateless: jest.fn(),
|
||||
}),
|
||||
context,
|
||||
}) as any;
|
||||
|
||||
const makeChangePayload = (actor: string): onChangePayload =>
|
||||
({
|
||||
documentName: DOCUMENT_NAME,
|
||||
context: { user: { id: 'user-1' }, actor },
|
||||
}) as any;
|
||||
|
||||
const sourceOf = (pageRepo: { updatePage: jest.Mock }) =>
|
||||
pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource;
|
||||
|
||||
it("tags 'user' for a plain write (no agent touch, no git-sync actor)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'user-1' }, actor: 'user' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('user');
|
||||
});
|
||||
|
||||
it("tags 'git-sync' when the writer's actor is 'git-sync' and no agent touched the window", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('git-sync');
|
||||
});
|
||||
|
||||
it("keeps 'agent' even when the storing writer is 'git-sync' (agent > git-sync)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
// An agent edit landed earlier in the coalescing window (sticky marker),
|
||||
// then a git-sync writer performs the store. Agent precedence must win.
|
||||
await ext.onChange(makeChangePayload('agent'));
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('agent');
|
||||
});
|
||||
|
||||
it("tags 'agent' when the storing writer itself is the agent (no prior onChange)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'agent-user' }, actor: 'agent' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('agent');
|
||||
});
|
||||
|
||||
// --- negative: a git-sync store must NOT pin a boundary history snapshot ----
|
||||
// The boundary-snapshot branch only fires when the resolved source is 'agent'
|
||||
// AND the prior persisted source is not 'agent'. A git-sync store resolves to
|
||||
// 'git-sync', so saveHistory must NOT be called.
|
||||
it('does NOT write a boundary history snapshot for a git-sync store', async () => {
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DOES pin a boundary snapshot for an agent store over a prior human state (control)', async () => {
|
||||
// Confirms the negative above is meaningful: under the SAME mocks, an agent
|
||||
// store over a 'user' baseline DOES trigger the boundary snapshot.
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'agent-user' }, actor: 'agent' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT pin a boundary snapshot for a plain user store', async () => {
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'user-1' }, actor: 'user' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -225,6 +225,83 @@ const CORPUS: Record<string, any> = {
|
||||
],
|
||||
}),
|
||||
|
||||
// --- editor-ext nodes/marks beyond the original corpus (item #7) ----------
|
||||
// Each of these was verified to round-trip CLEANLY through the real gate
|
||||
// (export -> markdown -> import -> editor-ext Yjs write path). Fixtures are
|
||||
// pre-authored at the engine's normalize-on-write fixpoint (SPEC §11), e.g.
|
||||
// details carries the materialized `open:false`, and color marks use the
|
||||
// `rgb(...)` form the HTML re-parser normalizes to.
|
||||
|
||||
'mention (user)': doc(
|
||||
para(
|
||||
text('hi '),
|
||||
{
|
||||
type: 'mention',
|
||||
attrs: {
|
||||
id: 'user-123',
|
||||
label: 'Alice',
|
||||
entityType: 'user',
|
||||
entityId: 'user-123',
|
||||
creatorId: 'creator-1',
|
||||
},
|
||||
},
|
||||
text(' there'),
|
||||
),
|
||||
),
|
||||
|
||||
'inline math': doc(
|
||||
para(
|
||||
text('inline '),
|
||||
{ type: 'mathInline', attrs: { text: 'x^2' } },
|
||||
text(' math'),
|
||||
),
|
||||
),
|
||||
|
||||
'block math': doc({ type: 'mathBlock', attrs: { text: 'x^2 + y^2 = z^2' } }),
|
||||
|
||||
'details (collapsible)': doc({
|
||||
type: 'details',
|
||||
// `open:false` is the value editor-ext materializes on import; pre-authoring
|
||||
// it puts the fixture at its round-trip fixpoint.
|
||||
attrs: { open: false },
|
||||
content: [
|
||||
{ type: 'detailsSummary', content: [text('Summary line')] },
|
||||
{ type: 'detailsContent', content: [para(text('hidden body'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
'highlight (mark, no color)': doc(
|
||||
para(
|
||||
text('a '),
|
||||
text('highlighted', [{ type: 'highlight' }]),
|
||||
text(' word'),
|
||||
),
|
||||
),
|
||||
|
||||
'highlight (mark, with color)': doc(
|
||||
para(
|
||||
text('a '),
|
||||
text('red', [{ type: 'highlight', attrs: { color: 'rgb(255, 0, 0)' } }]),
|
||||
text(' word'),
|
||||
),
|
||||
),
|
||||
|
||||
'subscript': doc(
|
||||
para(text('H'), text('2', [{ type: 'subscript' }]), text('O')),
|
||||
),
|
||||
|
||||
'superscript': doc(
|
||||
para(text('E=mc'), text('2', [{ type: 'superscript' }])),
|
||||
),
|
||||
|
||||
'text color (textStyle)': doc(
|
||||
// The HTML re-parser normalizes CSS colors to the `rgb(...)` form, so the
|
||||
// fixture pre-authors that form; a `#hex` color would round-trip to the
|
||||
// equivalent rgb() and is therefore a value-normalization divergence (see
|
||||
// the KNOWN DIVERGENCE block below).
|
||||
para(text('green', [{ type: 'textStyle', attrs: { color: 'rgb(0, 255, 0)' } }])),
|
||||
),
|
||||
|
||||
'nested / mixed document': doc(
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [text('Mixed')] },
|
||||
para(
|
||||
@@ -347,3 +424,92 @@ describe('git-sync converter §13.1 KNOWN DIVERGENCE (markdown image lossiness)'
|
||||
expect(docsCanonicallyEqual(imageDoc, canonNormalized)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KNOWN DIVERGENCE — text alignment (item #7; isolated, not silently dropped).
|
||||
//
|
||||
// editor-ext registers TextAlign for heading+paragraph, and the SERVER schema
|
||||
// fully supports it — the loss is intrinsic to the MARKDOWN transport:
|
||||
//
|
||||
// • A paragraph's `textAlign` is EXPORTED as `<div align="...">text</div>`
|
||||
// (markdown-converter case "paragraph"), but on import the converter's
|
||||
// docmost-schema declares `textAlign` WITHOUT a parseHTML mapping, so the
|
||||
// `align` attribute is never recovered -> it imports as `textAlign:null`
|
||||
// and canonicalizes away. A heading's alignment is not even exported.
|
||||
// • Therefore any non-default alignment is dropped on a full round trip.
|
||||
//
|
||||
// If the converter is ever taught to parse `align`/`text-align` back onto the
|
||||
// block, this assertion flips and an aligned-paragraph fixture should be
|
||||
// promoted into the green CORPUS above.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 KNOWN DIVERGENCE (text alignment dropped)', () => {
|
||||
it('drops a paragraph textAlign on the markdown round trip', async () => {
|
||||
const alignedDoc = doc({
|
||||
type: 'paragraph',
|
||||
attrs: { textAlign: 'center' },
|
||||
content: [text('centered')],
|
||||
});
|
||||
|
||||
const { canonNormalized } = await runGate(alignedDoc);
|
||||
|
||||
// The round-tripped paragraph carries no alignment.
|
||||
expect(canonNormalized).toEqual({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'centered' }] }],
|
||||
});
|
||||
expect(docsCanonicallyEqual(alignedDoc, canonNormalized)).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a heading textAlign (headings do not export alignment at all)', async () => {
|
||||
const alignedHeading = doc({
|
||||
type: 'heading',
|
||||
attrs: { level: 2, textAlign: 'center' },
|
||||
content: [text('centered heading')],
|
||||
});
|
||||
|
||||
const { md, canonNormalized } = await runGate(alignedHeading);
|
||||
|
||||
// Export is a plain markdown heading — no alignment syntax.
|
||||
expect(md.trim()).toBe('## centered heading');
|
||||
expect(docsCanonicallyEqual(alignedHeading, canonNormalized)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KNOWN DIVERGENCE — textStyle color is VALUE-NORMALIZED, not lost (item #7).
|
||||
//
|
||||
// The textStyle/color mark itself round-trips (the green CORPUS has the rgb()
|
||||
// form). But a `#hex` color is normalized to the equivalent `rgb(...)` string
|
||||
// by the HTML re-parser on import, and canonicalize.ts does NOT normalize color
|
||||
// formats — so a `#hex` original is not STRING-identical to its round trip even
|
||||
// though the color is semantically preserved. Locked here so the boundary is
|
||||
// explicit: author color fixtures in rgb() form to stay in the green corpus.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 KNOWN DIVERGENCE (textStyle color #hex -> rgb)', () => {
|
||||
it('normalizes a #hex text color to rgb() (semantically preserved, string-divergent)', async () => {
|
||||
const hexDoc = doc(
|
||||
para(text('green', [{ type: 'textStyle', attrs: { color: '#00ff00' } }])),
|
||||
);
|
||||
|
||||
const { canonNormalized } = await runGate(hexDoc);
|
||||
|
||||
// Color survives, but as the normalized rgb() string.
|
||||
expect(canonNormalized).toEqual({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'green',
|
||||
marks: [{ type: 'textStyle', attrs: { color: 'rgb(0, 255, 0)' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// Not string-identical to the #hex original.
|
||||
expect(docsCanonicallyEqual(hexDoc, canonNormalized)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { PageService } from './page.service';
|
||||
import { MovePageDto } from '../dto/move-page.dto';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
|
||||
|
||||
// Direct instantiation with stub deps. The Test.createTestingModule form failed
|
||||
// to resolve the @InjectKysely()/@InjectQueue() tokens at compile(), and this
|
||||
@@ -389,4 +390,219 @@ describe('PageService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('git-sync provenance stamping (#1)', () => {
|
||||
const GIT_SYNC: AuthProvenanceData = { actor: 'git-sync', aiChatId: null };
|
||||
const USER_PROVENANCE: AuthProvenanceData = { actor: 'user', aiChatId: null };
|
||||
|
||||
describe('create()', () => {
|
||||
// Build a service whose insertPage/generalQueue are observable and whose
|
||||
// nextPagePosition (a DB query) is stubbed, so create() reaches insertPage
|
||||
// without a real database.
|
||||
const makeService = () => {
|
||||
const insertedPage = { id: 'page-1', slugId: 'slug-1' };
|
||||
const pageRepo = {
|
||||
insertPage: jest.fn().mockResolvedValue(insertedPage),
|
||||
};
|
||||
// add() is fire-and-forget (the service .catch()es it); resolve so no
|
||||
// unhandled rejection leaks.
|
||||
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
generalQueue as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
// nextPagePosition runs a kysely query; stub it so create() never hits
|
||||
// the db. No DTO content is provided, so parseProsemirrorContent is
|
||||
// skipped entirely (content/textContent/ydoc stay undefined).
|
||||
jest.spyOn(svc, 'nextPagePosition').mockResolvedValue('a0');
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
const createDto: CreatePageDto = {
|
||||
title: 'New page',
|
||||
spaceId: 'space-1',
|
||||
} as any;
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the insertPage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.create('user-1', 'ws-1', createDto, GIT_SYNC);
|
||||
|
||||
expect(pageRepo.insertPage).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.insertPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ lastUpdatedSource: 'git-sync' }),
|
||||
);
|
||||
// git-sync carries no aiChatId (unlike the agent branch).
|
||||
const payload = pageRepo.insertPage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
// The human stays the responsible author.
|
||||
expect(payload.creatorId).toBe('user-1');
|
||||
expect(payload.lastUpdatedById).toBe('user-1');
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user create', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.create('user-1', 'ws-1', createDto, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.insertPage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update() (rename)', () => {
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
// update() re-reads the row at the end to return the refreshed page.
|
||||
findById: jest.fn().mockResolvedValue({ id: 'page-1' }),
|
||||
};
|
||||
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
aiQueue as any, // aiQueue
|
||||
generalQueue as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
const page: Page = {
|
||||
id: 'page-1',
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
title: 'Old title',
|
||||
icon: null,
|
||||
parentPageId: null,
|
||||
contributorIds: [],
|
||||
} as any;
|
||||
|
||||
const user: User = { id: 'user-1' } as any;
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the updatePage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
const dto: UpdatePageDto = { title: 'New title' } as any;
|
||||
|
||||
await svc.update(page, dto, user, GIT_SYNC);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBe('git-sync');
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
// The acting user stays the responsible author.
|
||||
expect(payload.lastUpdatedById).toBe('user-1');
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user rename', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
const dto: UpdatePageDto = { title: 'New title' } as any;
|
||||
|
||||
await svc.update(page, dto, user, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('movePage()', () => {
|
||||
const SPACE_ID = 'space-1';
|
||||
const VALID_POSITION = 'a0';
|
||||
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
id: 'dest-parent',
|
||||
deletedAt: null,
|
||||
spaceId: SPACE_ID,
|
||||
}),
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
};
|
||||
const eventEmitter = { emit: jest.fn() };
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
{} as any, // generalQueue
|
||||
eventEmitter as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
// No cycle: the destination's ancestor chain does not contain the moved
|
||||
// page, so movePage reaches updatePage.
|
||||
jest
|
||||
.spyOn(svc, 'getPageBreadCrumbs')
|
||||
.mockResolvedValue([{ id: 'dest-parent' }, { id: 'root' }] as any);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
const movedPage: Page = {
|
||||
id: 'page-1',
|
||||
parentPageId: 'old-parent',
|
||||
spaceId: SPACE_ID,
|
||||
workspaceId: 'ws-1',
|
||||
slugId: 'slug-1',
|
||||
title: 'Page 1',
|
||||
icon: null,
|
||||
} as any;
|
||||
|
||||
const dto: MovePageDto = {
|
||||
pageId: 'page-1',
|
||||
position: VALID_POSITION,
|
||||
parentPageId: 'dest-parent',
|
||||
};
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the updatePage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.movePage(dto, movedPage, GIT_SYNC);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBe('git-sync');
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user move', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.movePage(dto, movedPage, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,5 +92,64 @@ describe('SpaceService', () => {
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- audit delta on the git-sync toggle (test-strategy Module 4 / item #5)
|
||||
// updateSpace builds a before/after delta only when a flag's value actually
|
||||
// changes, and only logs an audit event when that delta is non-empty. These
|
||||
// assert that contract specifically for gitSyncEnabled.
|
||||
it('writes a SPACE_UPDATED audit delta on a REAL gitSyncEnabled change (false -> true)', async () => {
|
||||
// Prior persisted state: gitSync.enabled = false; the request flips it on.
|
||||
const { svc, auditService } = buildService({ gitSync: { enabled: false } });
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceId: spaceId,
|
||||
spaceId,
|
||||
changes: {
|
||||
before: expect.objectContaining({ gitSyncEnabled: false }),
|
||||
after: expect.objectContaining({ gitSyncEnabled: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('also records the delta when no prior gitSync settings exist (undefined -> true defaults prev to false)', async () => {
|
||||
// No gitSync key at all: prev resolves to the `?? false` default, so
|
||||
// enabling it is still a real change and is audited.
|
||||
const { svc, auditService } = buildService({});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
const call = auditService.log.mock.calls[0][0];
|
||||
expect(call.changes.before.gitSyncEnabled).toBe(false);
|
||||
expect(call.changes.after.gitSyncEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT write an audit delta on a no-op gitSyncEnabled (same value true -> true)', async () => {
|
||||
// Prior persisted state already true; the request sets the same value.
|
||||
// updateGitSyncSettings still runs (idempotent persist), but nothing is
|
||||
// added to the before/after delta, so no audit event is emitted.
|
||||
const { svc, spaceRepo, auditService } = buildService({
|
||||
gitSync: { enabled: true },
|
||||
});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
141
apps/server/src/database/repos/space/space.repo.spec.ts
Normal file
141
apps/server/src/database/repos/space/space.repo.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
Kysely,
|
||||
DummyDriver,
|
||||
PostgresAdapter,
|
||||
PostgresIntrospector,
|
||||
PostgresQueryCompiler,
|
||||
CompiledQuery,
|
||||
} from 'kysely';
|
||||
import { SpaceRepo } from './space.repo';
|
||||
import type { KyselyDB } from '../../types/kysely.types';
|
||||
|
||||
/**
|
||||
* SQL-builder unit test for the jsonb-merge invariant of
|
||||
* SpaceRepo.updateGitSyncSettings (review comment #694 / test-strategy item #6).
|
||||
*
|
||||
* The merge is RAW SQL, so a behavioural test would need a live Postgres — which
|
||||
* is intentionally out of scope here (the reviewer's own §13.3 was deferred for
|
||||
* the same reason). Instead we follow the existing repo-spec convention
|
||||
* (ai-agent-roles.repo.spec.ts) of NOT executing: we compile the query with a
|
||||
* DummyDriver Postgres dialect and assert the generated SQL preserves sibling
|
||||
* keys. The structural invariant the SQL must encode:
|
||||
*
|
||||
* settings := COALESCE(settings, '{}') || jsonb_build_object('gitSync', ...)
|
||||
* gitSync := COALESCE(settings->'gitSync', '{}') || jsonb_build_object(key, value)
|
||||
*
|
||||
* The OUTER `||` merges into the existing top-level `settings`, so a sibling
|
||||
* top-level key (e.g. `sharing`) is preserved. The INNER COALESCE merges into
|
||||
* the existing `gitSync` object, so a sibling key inside gitSync (e.g. `other`)
|
||||
* is preserved. A naive `set settings = jsonb_build_object('gitSync', ...)`
|
||||
* would clobber both — this test guards exactly that regression.
|
||||
*/
|
||||
describe('SpaceRepo.updateGitSyncSettings — jsonb merge SQL', () => {
|
||||
// A real Kysely on the Postgres dialect, but with a DummyDriver: it compiles
|
||||
// queries to real Postgres SQL without ever opening a connection.
|
||||
function makeCompileOnlyDb() {
|
||||
return new Kysely<any>({
|
||||
dialect: {
|
||||
createAdapter: () => new PostgresAdapter(),
|
||||
createDriver: () => new DummyDriver(),
|
||||
createIntrospector: (db) => new PostgresIntrospector(db),
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Build the repo over the compile-only db. The repo terminates the query with
|
||||
// `.executeTakeFirst()`, so we wrap every kysely builder in a Proxy: when the
|
||||
// repo finally calls `executeTakeFirst`, we `.compile()` that same builder
|
||||
// ourselves to capture the exact SQL it was about to run, then delegate.
|
||||
function makeRepoCapturingSql() {
|
||||
const db = makeCompileOnlyDb();
|
||||
let captured: CompiledQuery | undefined;
|
||||
|
||||
// kysely builders are immutable — each .set()/.where()/.returningAll()
|
||||
// returns a NEW builder — so re-wrap any chainable result.
|
||||
const wrap = (b: any): any =>
|
||||
new Proxy(b, {
|
||||
get(target, prop, receiver) {
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
if (typeof value !== 'function') return value;
|
||||
return (...callArgs: unknown[]) => {
|
||||
// Capture the SQL at the terminal execute call.
|
||||
if (
|
||||
(prop === 'executeTakeFirst' || prop === 'execute') &&
|
||||
typeof target.compile === 'function'
|
||||
) {
|
||||
captured = target.compile();
|
||||
}
|
||||
const result = value.apply(target, callArgs);
|
||||
if (
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
typeof (result as any).compile === 'function'
|
||||
) {
|
||||
return wrap(result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const originalUpdateTable = db.updateTable.bind(db);
|
||||
jest
|
||||
.spyOn(db, 'updateTable')
|
||||
.mockImplementation((...args: Parameters<typeof originalUpdateTable>) =>
|
||||
wrap(originalUpdateTable(...args)),
|
||||
);
|
||||
|
||||
const repo = new SpaceRepo(db as unknown as KyselyDB, {} as any);
|
||||
return { repo, getCaptured: () => captured };
|
||||
}
|
||||
|
||||
it("compiles a jsonb merge that preserves sibling top-level and gitSync keys", async () => {
|
||||
const { repo, getCaptured } = makeRepoCapturingSql();
|
||||
|
||||
// DummyDriver yields no rows; executeTakeFirst resolves to undefined. The
|
||||
// SQL is fully compiled by then, which is all we assert.
|
||||
await repo.updateGitSyncSettings('space-1', 'ws-1', 'enabled', true);
|
||||
|
||||
const compiled = getCaptured();
|
||||
expect(compiled).toBeDefined();
|
||||
// The raw SQL template carries newlines/indentation; collapse whitespace so
|
||||
// the structural assertions are not coupled to source formatting.
|
||||
const sql = compiled!.sql.replace(/\s+/g, ' ');
|
||||
|
||||
// OUTER merge into the existing settings object -> sibling top-level keys
|
||||
// (e.g. `sharing`) survive (NOT a bare jsonb_build_object assignment).
|
||||
expect(sql).toContain(`set "settings" = COALESCE(settings, '{}'::jsonb) ||`);
|
||||
// INNER merge into the existing gitSync object -> sibling gitSync keys
|
||||
// (e.g. `other`) survive.
|
||||
expect(sql).toContain(
|
||||
`jsonb_build_object('gitSync', COALESCE(settings->'gitSync', '{}'::jsonb) ||`,
|
||||
);
|
||||
// The pref key is set via jsonb_build_object on the inner object.
|
||||
expect(sql).toContain(`jsonb_build_object('enabled',`);
|
||||
// Scoped to the row + workspace.
|
||||
expect(sql).toContain(`where "id" =`);
|
||||
expect(sql).toContain(`and "workspaceId" =`);
|
||||
|
||||
// Sanity: this is NOT a clobbering assignment (no top-level
|
||||
// `set "settings" = jsonb_build_object(` without the COALESCE/merge).
|
||||
expect(sql).not.toContain(`set "settings" = jsonb_build_object(`);
|
||||
|
||||
// The pref VALUE is inlined via sql.lit (matches the repo's sql.lit usage);
|
||||
// updatedAt + id + workspaceId are the only bound parameters (the jsonb
|
||||
// merge text is all literal). updatedAt is a Date, so assert id/workspaceId.
|
||||
expect(compiled!.parameters).toContain('space-1');
|
||||
expect(compiled!.parameters).toContain('ws-1');
|
||||
});
|
||||
|
||||
it('inlines the prefKey/prefValue literally (sql.raw key, sql.lit value)', async () => {
|
||||
const { repo, getCaptured } = makeRepoCapturingSql();
|
||||
|
||||
await repo.updateGitSyncSettings('space-1', 'ws-1', 'enabled', false);
|
||||
|
||||
const sql = getCaptured()!.sql.replace(/\s+/g, ' ');
|
||||
// key via sql.raw + value via sql.lit -> both appear literally in the
|
||||
// inner build object (no bound parameter for either).
|
||||
expect(sql).toContain(`jsonb_build_object('enabled', false)`);
|
||||
});
|
||||
});
|
||||
@@ -77,4 +77,70 @@ describe('EnvironmentService', () => {
|
||||
expect(withEnv('not-a-number').getGitSyncDebounceMs()).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
// getGitSyncDataDir reads two distinct keys (GIT_SYNC_DATA_DIR and DATA_DIR),
|
||||
// so this builder maps each key to a supplied value (and honours the fallback
|
||||
// the getter passes for DATA_DIR's `|| './data'`).
|
||||
describe('getGitSyncDataDir', () => {
|
||||
const withEnv = (values: Record<string, string | undefined>) =>
|
||||
new EnvironmentService({
|
||||
get: (key: string, fallback?: string) => values[key] ?? fallback,
|
||||
} as any);
|
||||
|
||||
it("defaults to './data/git-sync' when neither key is set", () => {
|
||||
expect(withEnv({}).getGitSyncDataDir()).toBe('./data/git-sync');
|
||||
});
|
||||
|
||||
it('derives from DATA_DIR with the /git-sync suffix', () => {
|
||||
expect(
|
||||
withEnv({ DATA_DIR: '/var/lib/docmost' }).getGitSyncDataDir(),
|
||||
).toBe('/var/lib/docmost/git-sync');
|
||||
});
|
||||
|
||||
it('strips trailing slashes from DATA_DIR before appending', () => {
|
||||
expect(
|
||||
withEnv({ DATA_DIR: '/var/lib/docmost///' }).getGitSyncDataDir(),
|
||||
).toBe('/var/lib/docmost/git-sync');
|
||||
});
|
||||
|
||||
it('lets an explicit GIT_SYNC_DATA_DIR override the DATA_DIR derivation', () => {
|
||||
expect(
|
||||
withEnv({
|
||||
GIT_SYNC_DATA_DIR: '/custom/vault',
|
||||
DATA_DIR: '/var/lib/docmost',
|
||||
}).getGitSyncDataDir(),
|
||||
).toBe('/custom/vault');
|
||||
});
|
||||
|
||||
it('returns the explicit override verbatim (no /git-sync suffix, no slash strip)', () => {
|
||||
expect(
|
||||
withEnv({ GIT_SYNC_DATA_DIR: '/custom/vault/' }).getGitSyncDataDir(),
|
||||
).toBe('/custom/vault/');
|
||||
});
|
||||
});
|
||||
|
||||
// isGitSyncEnabled is the `.toLowerCase() === 'true'` contract: only a
|
||||
// case-insensitive "true" enables it; everything else (unset, "false",
|
||||
// garbage) is false.
|
||||
describe('isGitSyncEnabled', () => {
|
||||
const withEnv = (value?: string) =>
|
||||
new EnvironmentService({
|
||||
get: (_key: string, fallback?: string) => value ?? fallback,
|
||||
} as any);
|
||||
|
||||
it('is true for "true" and "TRUE" (case-insensitive)', () => {
|
||||
expect(withEnv('true').isGitSyncEnabled()).toBe(true);
|
||||
expect(withEnv('TRUE').isGitSyncEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when unset (defaults to "false")', () => {
|
||||
expect(withEnv().isGitSyncEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for "false" and garbage values', () => {
|
||||
expect(withEnv('false').isGitSyncEnabled()).toBe(false);
|
||||
expect(withEnv('maybe').isGitSyncEnabled()).toBe(false);
|
||||
expect(withEnv('1').isGitSyncEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { EnvironmentVariables } from './environment.validation';
|
||||
|
||||
/**
|
||||
* Validation-layer coverage for the git-sync env contract (test-strategy Module
|
||||
* 4 / item #4). We drive the decorated class with `validateSync` directly — the
|
||||
* exported `validate()` helper calls `process.exit(1)` on failure and so cannot
|
||||
* be asserted in-process. We only assert the git-sync rules, providing the
|
||||
* minimal always-required fields so unrelated validators do not add noise.
|
||||
*/
|
||||
describe('EnvironmentVariables — git-sync validation', () => {
|
||||
// A baseline config that satisfies the unconditionally-required fields
|
||||
// (DATABASE_URL, REDIS_URL, APP_SECRET) so the only errors we ever see come
|
||||
// from the git-sync rules under test.
|
||||
const baseConfig = {
|
||||
DATABASE_URL: 'postgres://user:pass@localhost:5432/docmost',
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
APP_SECRET: 'x'.repeat(32),
|
||||
};
|
||||
|
||||
const validate = (extra: Record<string, unknown>) => {
|
||||
const instance = plainToInstance(EnvironmentVariables, {
|
||||
...baseConfig,
|
||||
...extra,
|
||||
});
|
||||
return validateSync(instance);
|
||||
};
|
||||
|
||||
const errorFor = (errors: ReturnType<typeof validateSync>, property: string) =>
|
||||
errors.find((e) => e.property === property);
|
||||
|
||||
it('flags GIT_SYNC_SERVICE_USER_ID when GIT_SYNC_ENABLED="true" and the id is absent', () => {
|
||||
const errors = validate({ GIT_SYNC_ENABLED: 'true' });
|
||||
|
||||
const err = errorFor(errors, 'GIT_SYNC_SERVICE_USER_ID');
|
||||
expect(err).toBeDefined();
|
||||
// @IsNotEmpty is the failing constraint (sync is on but no attributable
|
||||
// author was configured).
|
||||
expect(err?.constraints).toHaveProperty('isNotEmpty');
|
||||
});
|
||||
|
||||
it('accepts GIT_SYNC_ENABLED="true" once GIT_SYNC_SERVICE_USER_ID is present', () => {
|
||||
const errors = validate({
|
||||
GIT_SYNC_ENABLED: 'true',
|
||||
GIT_SYNC_SERVICE_USER_ID: 'service-user-1',
|
||||
});
|
||||
|
||||
expect(errorFor(errors, 'GIT_SYNC_SERVICE_USER_ID')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not require the service user id when git-sync is disabled (unset)', () => {
|
||||
const errors = validate({});
|
||||
|
||||
// The @ValidateIf gate (GIT_SYNC_ENABLED === "true") is not met, so the
|
||||
// required-if-enabled rule is skipped entirely.
|
||||
expect(errorFor(errors, 'GIT_SYNC_SERVICE_USER_ID')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not require the service user id when git-sync is explicitly "false"', () => {
|
||||
const errors = validate({ GIT_SYNC_ENABLED: 'false' });
|
||||
|
||||
expect(errorFor(errors, 'GIT_SYNC_SERVICE_USER_ID')).toBeUndefined();
|
||||
expect(errorFor(errors, 'GIT_SYNC_ENABLED')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a GIT_SYNC_ENABLED value outside the {true,false} set via @IsIn', () => {
|
||||
const errors = validate({ GIT_SYNC_ENABLED: 'maybe' });
|
||||
|
||||
const err = errorFor(errors, 'GIT_SYNC_ENABLED');
|
||||
expect(err).toBeDefined();
|
||||
expect(err?.constraints).toHaveProperty('isIn');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// Unit tests for the ops/testing controller (plan §6). The orchestrator, env,
|
||||
// and the workspace-ability factory are hand-built mocks. We assert the admin
|
||||
// guard (non-admin -> ForbiddenException, no orchestrator call), that trigger
|
||||
// uses the workspace from request context (never the body), and that status
|
||||
// returns the env-derived object.
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../../core/casl/interfaces/workspace-ability.type';
|
||||
import { GitSyncController } from './git-sync.controller';
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
interface Built {
|
||||
controller: GitSyncController;
|
||||
orchestrator: { runOnce: AnyMock };
|
||||
env: Record<string, AnyMock>;
|
||||
workspaceAbility: { createForUser: AnyMock };
|
||||
ability: { cannot: AnyMock };
|
||||
}
|
||||
|
||||
function build(opts: { cannot?: boolean } = {}): Built {
|
||||
const { cannot = false } = opts;
|
||||
const ability = { cannot: jest.fn(() => cannot) };
|
||||
const workspaceAbility = { createForUser: jest.fn(() => ability) };
|
||||
|
||||
const orchestrator = {
|
||||
runOnce: jest.fn(async () => ({ spaceId: 'space-1', ran: true })),
|
||||
};
|
||||
const env: Record<string, AnyMock> = {
|
||||
isGitSyncEnabled: jest.fn(() => true),
|
||||
getGitSyncDataDir: jest.fn(() => '/vaults'),
|
||||
getGitSyncPollIntervalMs: jest.fn(() => 15000),
|
||||
getGitSyncDebounceMs: jest.fn(() => 2000),
|
||||
getGitSyncServiceUserId: jest.fn(() => 'svc-user'),
|
||||
};
|
||||
|
||||
const controller = new GitSyncController(
|
||||
orchestrator as any,
|
||||
env as any,
|
||||
workspaceAbility as any,
|
||||
);
|
||||
return { controller, orchestrator, env, workspaceAbility, ability };
|
||||
}
|
||||
|
||||
const USER = { id: 'user-1' } as any;
|
||||
const WORKSPACE = { id: 'ctx-ws' } as any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GitSyncController', () => {
|
||||
describe('trigger', () => {
|
||||
it('blocks a non-admin: throws ForbiddenException and never calls runOnce', async () => {
|
||||
const { controller, orchestrator, ability } = build({ cannot: true });
|
||||
|
||||
await expect(
|
||||
controller.trigger({ spaceId: 'space-1' } as any, USER, WORKSPACE),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(ability.cannot).toHaveBeenCalledWith(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.Settings,
|
||||
);
|
||||
expect(orchestrator.runOnce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('admin: calls runOnce(dto.spaceId, workspace.id) using the workspace from context', async () => {
|
||||
const { controller, orchestrator } = build({ cannot: false });
|
||||
|
||||
// The body carries an attacker-controlled workspaceId that must be ignored.
|
||||
const res = await controller.trigger(
|
||||
{ spaceId: 'space-1', workspaceId: 'evil-ws' } as any,
|
||||
USER,
|
||||
WORKSPACE,
|
||||
);
|
||||
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('space-1', 'ctx-ws');
|
||||
expect(res).toEqual({ spaceId: 'space-1', ran: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('status', () => {
|
||||
it('blocks a non-admin: throws ForbiddenException and never reads env', async () => {
|
||||
const { controller, env, ability } = build({ cannot: true });
|
||||
|
||||
await expect(controller.status(USER, WORKSPACE)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
|
||||
expect(ability.cannot).toHaveBeenCalledWith(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.Settings,
|
||||
);
|
||||
// The admin guard short-circuits before the env-derived status is built.
|
||||
expect(env.isGitSyncEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('admin: returns the env-derived status object', async () => {
|
||||
const { controller } = build({ cannot: false });
|
||||
|
||||
const res = await controller.status(USER, WORKSPACE);
|
||||
|
||||
expect(res).toEqual({
|
||||
enabled: true,
|
||||
dataDir: '/vaults',
|
||||
pollIntervalMs: 15000,
|
||||
debounceMs: 2000,
|
||||
serviceUserConfigured: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
// Unit tests for the event-driven git-sync trigger (plan §10). The orchestrator
|
||||
// and page repo are hand-built mocks; the debounce coalescing is exercised with
|
||||
// jest fake timers. We assert the gate, the loop-guard (anti-echo), the
|
||||
// missing-page short-circuit, the heterogeneous event-shape id resolution, the
|
||||
// debounce collapse, and that errors are swallowed + logged.
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { PageChangeListener } from './page-change.listener';
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
interface Built {
|
||||
listener: PageChangeListener;
|
||||
env: { isGitSyncEnabled: AnyMock; getGitSyncDebounceMs: AnyMock };
|
||||
orchestrator: { runOnce: AnyMock };
|
||||
pageRepo: { findById: AnyMock };
|
||||
}
|
||||
|
||||
function build(opts: { enabled?: boolean; debounceMs?: number } = {}): Built {
|
||||
const { enabled = true, debounceMs = 2000 } = opts;
|
||||
const env = {
|
||||
isGitSyncEnabled: jest.fn(() => enabled),
|
||||
getGitSyncDebounceMs: jest.fn(() => debounceMs),
|
||||
};
|
||||
const orchestrator = { runOnce: jest.fn(async () => undefined) };
|
||||
const pageRepo = { findById: jest.fn() };
|
||||
|
||||
const listener = new PageChangeListener(
|
||||
env as any,
|
||||
orchestrator as any,
|
||||
pageRepo as any,
|
||||
);
|
||||
return { listener, env, orchestrator, pageRepo };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PageChangeListener', () => {
|
||||
describe('gate', () => {
|
||||
it('does nothing when git-sync is disabled (no findById, no schedule)', async () => {
|
||||
const { listener, orchestrator, pageRepo } = build({ enabled: false });
|
||||
await listener.handlePageEvent({ pageId: 'p1', workspaceId: 'ws-1' });
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
expect(orchestrator.runOnce).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loop-guard (anti-echo)', () => {
|
||||
it("does NOT schedule a cycle when the page row's source is 'git-sync'", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { listener, orchestrator, pageRepo } = build();
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
lastUpdatedSource: 'git-sync',
|
||||
});
|
||||
await listener.handlePageEvent({ pageId: 'p1', workspaceId: 'ws-1' });
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(orchestrator.runOnce).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('schedules exactly one cycle for a normal (non-git-sync) source', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { listener, orchestrator, pageRepo } = build();
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
lastUpdatedSource: 'user',
|
||||
});
|
||||
await listener.handlePageEvent({ pageId: 'p1', workspaceId: 'ws-1' });
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledTimes(1);
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('space-1', 'ws-1');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing page', () => {
|
||||
it('does not schedule when findById returns null/undefined', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { listener, orchestrator, pageRepo } = build();
|
||||
pageRepo.findById.mockResolvedValue(undefined);
|
||||
await listener.handlePageEvent({ pageId: 'p1', workspaceId: 'ws-1' });
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(orchestrator.runOnce).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('spaceId/workspaceId resolution', () => {
|
||||
// The page row used to fill in any ids the event omits.
|
||||
const pageRow = {
|
||||
id: 'p1',
|
||||
spaceId: 'row-space',
|
||||
workspaceId: 'row-ws',
|
||||
lastUpdatedSource: 'user',
|
||||
};
|
||||
|
||||
async function resolve(event: Record<string, unknown>) {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { listener, orchestrator, pageRepo } = build();
|
||||
pageRepo.findById.mockResolvedValue(pageRow);
|
||||
await listener.handlePageEvent(event as any);
|
||||
jest.runOnlyPendingTimers();
|
||||
return { orchestrator, pageRepo };
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
}
|
||||
|
||||
it("resolves pageId + event.spaceId + event.workspaceId", async () => {
|
||||
const { orchestrator, pageRepo } = await resolve({
|
||||
pageId: 'p1',
|
||||
spaceId: 'evt-space',
|
||||
workspaceId: 'evt-ws',
|
||||
});
|
||||
expect(pageRepo.findById).toHaveBeenCalledWith('p1', { includeContent: false });
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('evt-space', 'evt-ws');
|
||||
});
|
||||
|
||||
it('resolves pageId from pageIds[0]', async () => {
|
||||
const { orchestrator, pageRepo } = await resolve({
|
||||
pageIds: ['p1', 'p2'],
|
||||
spaceId: 'evt-space',
|
||||
workspaceId: 'evt-ws',
|
||||
});
|
||||
expect(pageRepo.findById).toHaveBeenCalledWith('p1', { includeContent: false });
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('evt-space', 'evt-ws');
|
||||
});
|
||||
|
||||
it('resolves pageId + spaceId from pages[]', async () => {
|
||||
const { orchestrator } = await resolve({
|
||||
pages: [{ id: 'p1', spaceId: 'pages-space' }],
|
||||
workspaceId: 'evt-ws',
|
||||
});
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('pages-space', 'evt-ws');
|
||||
});
|
||||
|
||||
it('resolves pageId + spaceId from node', async () => {
|
||||
const { orchestrator } = await resolve({
|
||||
node: { id: 'p1', spaceId: 'node-space' },
|
||||
workspaceId: 'evt-ws',
|
||||
});
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('node-space', 'evt-ws');
|
||||
});
|
||||
|
||||
it('falls back to the fetched page row when the event omits spaceId/workspaceId', async () => {
|
||||
const { orchestrator } = await resolve({ pageId: 'p1' });
|
||||
// No spaceId/workspaceId on the event -> use the page row's values.
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('row-space', 'row-ws');
|
||||
});
|
||||
});
|
||||
|
||||
describe('debounce coalescing', () => {
|
||||
it('collapses a burst of N events for one space into exactly one runOnce', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { listener, orchestrator, pageRepo } = build({ debounceMs: 500 });
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
lastUpdatedSource: 'user',
|
||||
});
|
||||
|
||||
// Fire a burst of 5 events; await each so its findById promise settles
|
||||
// and schedule() runs before the next event resets the timer.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await listener.handlePageEvent({ pageId: 'p1', workspaceId: 'ws-1' });
|
||||
}
|
||||
|
||||
// Nothing fired yet (still within the debounce window).
|
||||
expect(orchestrator.runOnce).not.toHaveBeenCalled();
|
||||
|
||||
// Advance past the debounce window: the coalesced cycle fires once.
|
||||
jest.advanceTimersByTime(500);
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledTimes(1);
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('space-1', 'ws-1');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('error swallowing', () => {
|
||||
it('does not throw and logs a warning when findById throws', async () => {
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const { listener, orchestrator, pageRepo } = build();
|
||||
pageRepo.findById.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
listener.handlePageEvent({ pageId: 'p1', workspaceId: 'ws-1' }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(warnSpy.mock.calls[0][0])).toContain('db down');
|
||||
expect(orchestrator.runOnce).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
// Unit tests for the git-sync control plane (plan §9/§10/§11). The vendored
|
||||
// engine (@docmost/git-sync) is fully mocked so we exercise ONLY the
|
||||
// orchestrator's wiring: gating, the Redis leader lock + in-process mutex,
|
||||
// the pull/push call order, the delete-cap anti-data-loss guard, the remote
|
||||
// template substitution, and the idempotent interval lifecycle.
|
||||
//
|
||||
// The engine mock must be declared before importing the orchestrator so the
|
||||
// module-graph import binds to the mocked functions (same idiom as the
|
||||
// datasource spec's top-of-file jest.mock stubs that avoid the React graph).
|
||||
jest.mock('@docmost/git-sync', () => ({
|
||||
readExisting: jest.fn(),
|
||||
computePullActions: jest.fn(),
|
||||
applyPullActions: jest.fn(),
|
||||
runPush: jest.fn(),
|
||||
}));
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
readExisting,
|
||||
computePullActions,
|
||||
applyPullActions,
|
||||
runPush,
|
||||
} from '@docmost/git-sync';
|
||||
import { GitSyncOrchestrator } from './git-sync.orchestrator';
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
const readExistingMock = readExisting as unknown as AnyMock;
|
||||
const computePullActionsMock = computePullActions as unknown as AnyMock;
|
||||
const applyPullActionsMock = applyPullActions as unknown as AnyMock;
|
||||
const runPushMock = runPush as unknown as AnyMock;
|
||||
|
||||
interface BuildOptions {
|
||||
/** Env tunables (only the load-bearing ones are surfaced as overrides). */
|
||||
enabled?: boolean;
|
||||
serviceUserId?: string | undefined;
|
||||
maxDeletes?: number;
|
||||
remoteTemplate?: string | undefined;
|
||||
dataDir?: string;
|
||||
pollIntervalMs?: number;
|
||||
debounceMs?: number;
|
||||
/** A hook applied to the fake vault so a test can override its behaviour. */
|
||||
vaultOverrides?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface Built {
|
||||
orchestrator: GitSyncOrchestrator;
|
||||
env: Record<string, AnyMock>;
|
||||
dataSource: { bind: AnyMock };
|
||||
client: Record<string, AnyMock>;
|
||||
vaultRegistry: { getVault: AnyMock; vaultPath: AnyMock };
|
||||
vault: Record<string, AnyMock>;
|
||||
scheduler: Record<string, AnyMock>;
|
||||
redis: { set: AnyMock; eval: AnyMock };
|
||||
redisService: { getOrThrow: AnyMock };
|
||||
db: unknown;
|
||||
}
|
||||
|
||||
function build(opts: BuildOptions = {}): Built {
|
||||
const {
|
||||
enabled = true,
|
||||
maxDeletes = 100,
|
||||
remoteTemplate = undefined,
|
||||
dataDir = '/vaults',
|
||||
pollIntervalMs = 15000,
|
||||
debounceMs = 2000,
|
||||
vaultOverrides = {},
|
||||
} = opts;
|
||||
// Distinguish "key omitted" (default to a valid id) from "key present but
|
||||
// undefined" (the no-service-user test deliberately sets it undefined).
|
||||
const serviceUserId = 'serviceUserId' in opts ? opts.serviceUserId : 'svc-user';
|
||||
|
||||
const env: Record<string, AnyMock> = {
|
||||
isGitSyncEnabled: jest.fn(() => enabled),
|
||||
getGitSyncServiceUserId: jest.fn(() => serviceUserId),
|
||||
getGitSyncMaxDeletesPerCycle: jest.fn(() => maxDeletes),
|
||||
getGitSyncRemoteTemplate: jest.fn(() => remoteTemplate),
|
||||
getGitSyncDataDir: jest.fn(() => dataDir),
|
||||
getGitSyncPollIntervalMs: jest.fn(() => pollIntervalMs),
|
||||
getGitSyncDebounceMs: jest.fn(() => debounceMs),
|
||||
};
|
||||
|
||||
// The read-side / write-side client the datasource hands back.
|
||||
const client: Record<string, AnyMock> = {
|
||||
listSpaceTree: jest.fn(async () => ({ pages: [], complete: true })),
|
||||
deletePage: jest.fn(async () => undefined),
|
||||
createPage: jest.fn(async () => undefined),
|
||||
updatePageBody: jest.fn(async () => undefined),
|
||||
};
|
||||
const dataSource = { bind: jest.fn(() => client) };
|
||||
|
||||
// The fake VaultGit: every method the orchestrator calls is a jest.fn.
|
||||
const vault: Record<string, AnyMock> = {
|
||||
assertGitAvailable: jest.fn(async () => undefined),
|
||||
ensureRepo: jest.fn(async () => undefined),
|
||||
isMergeInProgress: jest.fn(async () => false),
|
||||
ensureBranch: jest.fn(async () => undefined),
|
||||
checkout: jest.fn(async () => undefined),
|
||||
listTrackedFiles: jest.fn(async () => []),
|
||||
...(vaultOverrides as Record<string, AnyMock>),
|
||||
};
|
||||
const vaultRegistry = {
|
||||
getVault: jest.fn(async () => vault),
|
||||
vaultPath: jest.fn((spaceId: string) => `${dataDir}/${spaceId}`),
|
||||
};
|
||||
|
||||
const scheduler: Record<string, AnyMock> = {
|
||||
addInterval: jest.fn(),
|
||||
deleteInterval: jest.fn(),
|
||||
};
|
||||
|
||||
const redis = {
|
||||
// Default: lock acquired. Tests override per-case.
|
||||
set: jest.fn(async () => 'OK'),
|
||||
eval: jest.fn(async () => 1),
|
||||
};
|
||||
const redisService = { getOrThrow: jest.fn(() => redis) };
|
||||
|
||||
const db = {};
|
||||
|
||||
const orchestrator = new GitSyncOrchestrator(
|
||||
env as any,
|
||||
dataSource as any,
|
||||
vaultRegistry as any,
|
||||
scheduler as any,
|
||||
redisService as any,
|
||||
db as any,
|
||||
);
|
||||
|
||||
return {
|
||||
orchestrator,
|
||||
env,
|
||||
dataSource,
|
||||
client,
|
||||
vaultRegistry,
|
||||
vault,
|
||||
scheduler,
|
||||
redis,
|
||||
redisService,
|
||||
db,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reasonable engine defaults so a happy-path driveCycle completes. */
|
||||
function primeEngineHappyPath(): void {
|
||||
readExistingMock.mockResolvedValue({});
|
||||
computePullActionsMock.mockReturnValue({ creates: [], updates: [], deletes: [] });
|
||||
applyPullActionsMock.mockResolvedValue({
|
||||
written: 0,
|
||||
deleted: 0,
|
||||
merge: { conflict: false },
|
||||
});
|
||||
runPushMock.mockResolvedValue({ mode: 'apply', failures: [], planned: { deletes: 0 } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
primeEngineHappyPath();
|
||||
});
|
||||
|
||||
describe('GitSyncOrchestrator', () => {
|
||||
describe('runOnce gating', () => {
|
||||
it("short-circuits with skipped:'disabled' when git-sync is disabled", async () => {
|
||||
const { orchestrator, redis, vaultRegistry } = build({ enabled: false });
|
||||
const res = await orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res).toEqual({ spaceId: 'space-1', ran: false, skipped: 'disabled' });
|
||||
// No lock, no vault work performed.
|
||||
expect(redis.set).not.toHaveBeenCalled();
|
||||
expect(vaultRegistry.getVault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns skipped:'no-service-user' when the service user id is falsy", async () => {
|
||||
const { orchestrator, redis } = build({ serviceUserId: undefined });
|
||||
const res = await orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res).toEqual({
|
||||
spaceId: 'space-1',
|
||||
ran: false,
|
||||
skipped: 'no-service-user',
|
||||
});
|
||||
expect(redis.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('in-process mutex', () => {
|
||||
it("a second runOnce while the first is in-flight returns skipped:'in-progress'", async () => {
|
||||
const built = build();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
// Hang the first cycle inside driveCycle by stalling getVault.
|
||||
built.vaultRegistry.getVault.mockImplementationOnce(async () => {
|
||||
await gate;
|
||||
return built.vault;
|
||||
});
|
||||
|
||||
const first = built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
// Let the first call enter the running set + acquire the lock.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const second = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(second).toEqual({
|
||||
spaceId: 'space-1',
|
||||
ran: false,
|
||||
skipped: 'in-progress',
|
||||
});
|
||||
|
||||
release();
|
||||
await first;
|
||||
});
|
||||
});
|
||||
|
||||
describe('redis leader lock', () => {
|
||||
it("returns skipped:'lock-held' and cleans up the mutex when the lock is not acquired", async () => {
|
||||
const built = build();
|
||||
// First acquire fails (not 'OK'); a later acquire succeeds.
|
||||
built.redis.set
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue('OK');
|
||||
|
||||
const res = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res).toEqual({
|
||||
spaceId: 'space-1',
|
||||
ran: false,
|
||||
skipped: 'lock-held',
|
||||
});
|
||||
// The mutex must be clear: a subsequent call can acquire + run.
|
||||
const res2 = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res2.ran).toBe(true);
|
||||
expect(res2.skipped).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('poisoned-space protection', () => {
|
||||
it('releases the lock and clears the mutex when driveCycle throws, returning { error }', async () => {
|
||||
const built = build();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
// Make the real apply runPush reject; dry-run still resolves first.
|
||||
runPushMock
|
||||
.mockResolvedValueOnce({ mode: 'apply', failures: [], planned: { deletes: 0 } })
|
||||
.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
const res = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res.ran).toBe(false);
|
||||
expect(res.error).toBe('boom');
|
||||
// CAS release was invoked (eval) and the space is no longer "running":
|
||||
expect(built.redis.eval).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A subsequent call can re-acquire (mutex cleared after the throw).
|
||||
runPushMock.mockResolvedValue({ mode: 'apply', failures: [], planned: { deletes: 0 } });
|
||||
const res2 = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res2.ran).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge-in-progress guard', () => {
|
||||
it("returns skipped:'merge-in-progress' and runs no pull/push", async () => {
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const built = build({ vaultOverrides: { isMergeInProgress: jest.fn(async () => true) } });
|
||||
|
||||
const res = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res).toEqual({
|
||||
spaceId: 'space-1',
|
||||
ran: false,
|
||||
skipped: 'merge-in-progress',
|
||||
});
|
||||
expect(applyPullActionsMock).not.toHaveBeenCalled();
|
||||
expect(runPushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cycle order', () => {
|
||||
it('runs ensureRepo -> ensureBranch(docmost,main) -> checkout(docmost) -> applyPullActions in order', async () => {
|
||||
const order: string[] = [];
|
||||
const built = build({
|
||||
vaultOverrides: {
|
||||
ensureRepo: jest.fn(async () => {
|
||||
order.push('ensureRepo');
|
||||
}),
|
||||
ensureBranch: jest.fn(async (branch: string, base: string) => {
|
||||
order.push(`ensureBranch:${branch}:${base}`);
|
||||
}),
|
||||
checkout: jest.fn(async (branch: string) => {
|
||||
order.push(`checkout:${branch}`);
|
||||
}),
|
||||
},
|
||||
});
|
||||
applyPullActionsMock.mockImplementation(async () => {
|
||||
order.push('applyPullActions');
|
||||
return { written: 0, deleted: 0, merge: { conflict: false } };
|
||||
});
|
||||
|
||||
await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
|
||||
expect(order).toEqual([
|
||||
'ensureRepo',
|
||||
'ensureBranch:docmost:main',
|
||||
'checkout:docmost',
|
||||
'applyPullActions',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete cap (anti-data-loss)', () => {
|
||||
it('neutralizes deletePage on the apply client when planned deletes exceed the cap', async () => {
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const built = build({ maxDeletes: 5 });
|
||||
// Dry-run plans 9 deletes (over the cap of 5); apply still runs.
|
||||
runPushMock
|
||||
.mockResolvedValueOnce({ mode: 'plan', failures: [], planned: { deletes: 9 } })
|
||||
.mockResolvedValueOnce({ mode: 'apply', failures: [], planned: { deletes: 0 } });
|
||||
|
||||
const res = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res.ran).toBe(true);
|
||||
expect(runPushMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The second runPush (real apply, dryRun:false) got a neutralized client.
|
||||
const [applyDeps, applyOpts] = runPushMock.mock.calls[1];
|
||||
expect(applyOpts).toEqual({ dryRun: false });
|
||||
const applyClient = applyDeps.makeClient();
|
||||
// deletePage is still a function (the engine may call it)...
|
||||
expect(typeof applyClient.deletePage).toBe('function');
|
||||
await applyClient.deletePage('p1');
|
||||
// ...but it is a NO-OP: the underlying real deletePage was NOT invoked.
|
||||
expect(built.client.deletePage).not.toHaveBeenCalled();
|
||||
// Creates/updates pass through to the real client.
|
||||
expect(applyClient.createPage).toBe(built.client.createPage);
|
||||
});
|
||||
|
||||
it('fails safe: a throwing dry-run still suppresses deletes and does not throw', async () => {
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const built = build({ maxDeletes: 5 });
|
||||
runPushMock
|
||||
.mockRejectedValueOnce(new Error('plan failed'))
|
||||
.mockResolvedValueOnce({ mode: 'apply', failures: [], planned: { deletes: 0 } });
|
||||
|
||||
const res = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
// The cycle still completes (ran:true), it does NOT throw.
|
||||
expect(res.ran).toBe(true);
|
||||
const [applyDeps] = runPushMock.mock.calls[1];
|
||||
const applyClient = applyDeps.makeClient();
|
||||
await applyClient.deletePage('p1');
|
||||
expect(built.client.deletePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes through the original client when planned deletes are within the cap', async () => {
|
||||
const built = build({ maxDeletes: 5 });
|
||||
runPushMock
|
||||
.mockResolvedValueOnce({ mode: 'plan', failures: [], planned: { deletes: 3 } })
|
||||
.mockResolvedValueOnce({ mode: 'apply', failures: [], planned: { deletes: 0 } });
|
||||
|
||||
await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
const [applyDeps] = runPushMock.mock.calls[1];
|
||||
const applyClient = applyDeps.makeClient();
|
||||
// The ORIGINAL client is used (deletePage forwards to the real one).
|
||||
expect(applyClient).toBe(built.client);
|
||||
await applyClient.deletePage('p1');
|
||||
expect(built.client.deletePage).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote template substitution', () => {
|
||||
it('substitutes {spaceId} into the gitRemote handed to runPush', async () => {
|
||||
const built = build({ remoteTemplate: 'git@h:vault-{spaceId}.git' });
|
||||
await built.orchestrator.runOnce('space-42', 'ws-1');
|
||||
// Inspect the settings on the dry-run call (first runPush).
|
||||
const [dryDeps] = runPushMock.mock.calls[0];
|
||||
expect(dryDeps.settings.gitRemote).toBe('git@h:vault-space-42.git');
|
||||
});
|
||||
});
|
||||
|
||||
describe('module lifecycle', () => {
|
||||
it('registers exactly one interval on init and tears it down idempotently on destroy', () => {
|
||||
const built = build();
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
|
||||
built.orchestrator.onModuleInit();
|
||||
expect(built.scheduler.addInterval).toHaveBeenCalledTimes(1);
|
||||
const [name] = built.scheduler.addInterval.mock.calls[0];
|
||||
|
||||
built.orchestrator.onModuleDestroy();
|
||||
expect(built.scheduler.deleteInterval).toHaveBeenCalledTimes(1);
|
||||
expect(built.scheduler.deleteInterval).toHaveBeenCalledWith(name);
|
||||
|
||||
// A second destroy is a no-op (guard against double-delete).
|
||||
built.orchestrator.onModuleDestroy();
|
||||
expect(built.scheduler.deleteInterval).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('registers nothing on init when git-sync is disabled', () => {
|
||||
const built = build({ enabled: false });
|
||||
built.orchestrator.onModuleInit();
|
||||
expect(built.scheduler.addInterval).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// Unit tests for the per-space vault path resolver + lazy VaultGit cache
|
||||
// (plan §3/§5). `mkdir` and `VaultGit` are mocked so construction is cheap and
|
||||
// no real filesystem / git work happens. We assert the path normalization
|
||||
// (trailing slash) and the one-VaultGit-per-space caching contract.
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { VaultGit } from '@docmost/git-sync';
|
||||
|
||||
jest.mock('node:fs/promises', () => ({
|
||||
mkdir: jest.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
// Cheap VaultGit stub: records the path it was constructed with; no shell-out.
|
||||
jest.mock('@docmost/git-sync', () => ({
|
||||
VaultGit: jest.fn().mockImplementation((path: string) => ({ path })),
|
||||
}));
|
||||
|
||||
import { VaultRegistryService } from './vault-registry.service';
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
const mkdirMock = mkdir as unknown as AnyMock;
|
||||
const VaultGitMock = VaultGit as unknown as AnyMock;
|
||||
|
||||
function build(dataDir: string): { service: VaultRegistryService } {
|
||||
const env = {
|
||||
getGitSyncDataDir: jest.fn(() => dataDir),
|
||||
};
|
||||
const service = new VaultRegistryService(env as any);
|
||||
return { service };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('VaultRegistryService', () => {
|
||||
describe('vaultPath', () => {
|
||||
it('normalizes a trailing slash in the data dir (no double slash)', () => {
|
||||
const { service } = build('/vaults/');
|
||||
expect(service.vaultPath('space-1')).toBe('/vaults/space-1');
|
||||
});
|
||||
|
||||
it('works without a trailing slash too', () => {
|
||||
const { service } = build('/vaults');
|
||||
expect(service.vaultPath('space-1')).toBe('/vaults/space-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVault lazy cache', () => {
|
||||
it('returns the SAME instance on a second call (one VaultGit per space)', async () => {
|
||||
const { service } = build('/vaults');
|
||||
|
||||
const first = await service.getVault('space-1');
|
||||
const second = await service.getVault('space-1');
|
||||
|
||||
// Same cached instance, constructed exactly once.
|
||||
expect(second).toBe(first);
|
||||
expect(VaultGitMock).toHaveBeenCalledTimes(1);
|
||||
expect(VaultGitMock).toHaveBeenCalledWith('/vaults/space-1');
|
||||
// mkdir is only run on the first (cache-miss) construction.
|
||||
expect(mkdirMock).toHaveBeenCalledTimes(1);
|
||||
expect(mkdirMock).toHaveBeenCalledWith('/vaults/space-1', {
|
||||
recursive: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user