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>
204 lines
7.8 KiB
TypeScript
204 lines
7.8 KiB
TypeScript
// 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();
|
|
});
|
|
});
|