Files
gitmost/apps/server/src/collaboration/extensions/persistence-store.spec.ts
claude code agent 227 90a3fa012d test(#248 F3): make empty-over-empty test actually reach the store empty-guard
The "does not block an empty store over an already-empty page" test set the
stored page.content to TiptapTransformer.fromYdoc(document,'default') — exactly
the value tiptapJson is computed from — so isDeepStrictEqual(tiptapJson,
page.content) was TRUE and onStoreDocument RETURNED at the unchanged short-circuit
before ever reaching the empty-guard. It exercised the old short-circuit, not the
new guard's `!isEmptyParagraphDoc(page.content)` branch (the only NEW branch
protecting empty existing pages from over-blocking); the condition could be
removed and the test would still pass (false coverage).

Set stored content to an empty paragraph with `content: []` — empty per
isEmptyParagraphDoc but NOT deep-equal to the live doc (which normalizes to a
paragraph with `attrs: { indent: 0 }` and no content key). Execution now skips
the short-circuit and enters the guard; reorient the assertion to "the write is
NOT blocked" (updatePage IS called). Verified the test now FAILS if the
`!isEmptyParagraphDoc(page.content)` condition is removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 01:56:00 +03:00

284 lines
13 KiB
TypeScript

import { TiptapTransformer } from '@hocuspocus/transformer';
import { PersistenceExtension } from './persistence.extension';
import { tiptapExtensions } from '../collaboration.util';
/**
* Integration test for `onStoreDocument`'s Approach-A boundary snapshot.
*
* The data-loss risk: when an AGENT store lands over a page whose persisted
* state was authored by a HUMAN, the agent overwrites that human content. If we
* do not pin the human revision as its own history version BEFORE the agent's
* updatePage, the last human edit is lost. This test pins the ordering
* (saveHistory(oldHumanPage) strictly before updatePage) and the idempotency
* skip when content is unchanged.
*
* We pass a REAL Y.Doc as the `document` arg (so TiptapTransformer.fromYdoc
* yields real content) and stub repos/queues + an executeTx-compatible db whose
* transaction().execute() invokes the callback with a trx stub.
*/
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
const USER_ID = 'human-1';
// Build a real Y.Doc carrying the given tiptap JSON in the 'default' fragment.
// hocuspocus augments the live document with broadcastStateless(); the bare
// Y.Doc lacks it, so stub it for the post-store broadcast.
const ydocFor = (json: any) => {
const ydoc = TiptapTransformer.toYdoc(json, 'default', tiptapExtensions);
(ydoc as any).broadcastStateless = jest.fn();
return ydoc;
};
const doc = (text: string) => ({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
});
describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot', () => {
let ext: PersistenceExtension;
let pageRepo: { findById: jest.Mock; updatePage: jest.Mock };
let pageHistoryRepo: {
saveHistory: jest.Mock;
findPageLastHistory: jest.Mock;
};
let aiQueue: { add: jest.Mock };
let historyQueue: { add: jest.Mock };
let notificationQueue: { add: jest.Mock };
let collabHistory: { addContributors: jest.Mock };
let transclusionService: {
syncPageTransclusions: jest.Mock;
syncPageReferences: jest.Mock;
syncPageTemplateReferences: jest.Mock;
};
let callOrder: string[];
// db whose transaction().execute(fn) runs fn with a trx stub — this lets the
// real executeTx() helper drive the callback without a database.
const trxStub = { __trx: true };
const db = {
transaction: () => ({
execute: (fn: (trx: any) => Promise<any>) => fn(trxStub),
}),
};
// The persisted page row the transaction reads (OLD, human-authored state).
const persistedHumanPage = (newAgentText: string) => ({
id: PAGE_ID,
slugId: 'slug-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
creatorId: 'creator-1',
contributorIds: ['creator-1'],
createdAt: new Date('2020-01-01T00:00:00Z'),
lastUpdatedSource: 'user', // prior revision was human
// content differs from the new agent doc so the update branch runs.
content: doc('OLD HUMAN'),
_newAgentText: newAgentText,
});
const buildData = (document: any, actor: 'user' | 'agent') => ({
documentName: `page.${PAGE_ID}`,
document,
context: { user: { id: USER_ID, name: 'Alice' }, actor },
});
beforeEach(() => {
callOrder = [];
pageRepo = {
findById: jest.fn(),
updatePage: jest.fn().mockImplementation(async () => {
callOrder.push('updatePage');
}),
};
pageHistoryRepo = {
saveHistory: jest.fn().mockImplementation(async () => {
callOrder.push('saveHistory');
}),
findPageLastHistory: jest.fn().mockResolvedValue(null),
};
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
transclusionService = {
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
syncPageReferences: jest.fn().mockResolvedValue(undefined),
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
};
ext = new PersistenceExtension(
pageRepo as any,
pageHistoryRepo as any,
db as any,
aiQueue as any,
historyQueue as any,
notificationQueue as any,
collabHistory as any,
transclusionService as any,
);
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
jest.spyOn(ext['logger'], 'error').mockImplementation(() => undefined);
});
it('agent store over a human page pins saveHistory(oldHumanPage) BEFORE updatePage', async () => {
const document = ydocFor(doc('NEW AGENT CONTENT'));
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW AGENT CONTENT'));
// No human baseline snapshot exists yet → boundary snapshot must run.
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
await ext.onStoreDocument(buildData(document, 'agent') as any);
// Boundary snapshot fired, and strictly before the agent overwrite.
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
const saved = pageHistoryRepo.saveHistory.mock.calls[0][0];
expect(saved.content).toEqual(doc('OLD HUMAN')); // the OLD human revision
expect(callOrder).toEqual(['saveHistory', 'updatePage']);
// The agent's new content is tagged 'agent' on the update.
const update = pageRepo.updatePage.mock.calls[0][0];
expect(update.lastUpdatedSource).toBe('agent');
});
it('skips the boundary snapshot when the human baseline is already pinned', async () => {
const document = ydocFor(doc('NEW AGENT CONTENT'));
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW AGENT CONTENT'));
// Latest history already equals the current human state → no duplicate.
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
content: doc('OLD HUMAN'),
});
await ext.onStoreDocument(buildData(document, 'agent') as any);
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
});
it('human store does NOT trigger the boundary snapshot (no source transition)', async () => {
const document = ydocFor(doc('NEW HUMAN CONTENT'));
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN CONTENT'));
await ext.onStoreDocument(buildData(document, 'user') as any);
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
});
it('idempotency: unchanged content → no updatePage, no history, no queues', async () => {
// The Y.Doc content equals the persisted content deeply → early skip.
// A Y.Doc round-trip normalizes attrs (e.g. paragraph indent), so derive
// the persisted content from fromYdoc to make the deep-equal skip genuine.
const document = ydocFor(doc('SAME CONTENT'));
const normalized = TiptapTransformer.fromYdoc(document, 'default');
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('SAME CONTENT'),
content: normalized,
});
await ext.onStoreDocument(buildData(document, 'agent') as any);
expect(pageRepo.updatePage).not.toHaveBeenCalled();
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
});
// persist-1 — a transient DB failure during store must not silently lose the
// edit. hocuspocus unloads (destroys) the in-memory Y.Doc right after this
// hook resolves, so the store has to retry while it still holds the only copy.
it('retries a transient DB failure and still persists the edit (persist-1)', async () => {
const document = ydocFor(doc('NEW HUMAN CONTENT'));
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN CONTENT'));
let attempts = 0;
pageRepo.updatePage.mockImplementation(async () => {
attempts += 1;
if (attempts === 1) throw new Error('deadlock detected'); // transient
callOrder.push('updatePage');
});
await ext.onStoreDocument(buildData(document, 'user') as any);
// First attempt failed and rolled back; the retry persisted the edit.
expect(pageRepo.updatePage).toHaveBeenCalledTimes(2);
// The edit WAS saved, so the post-store success path runs as normal.
expect((document as any).broadcastStateless).toHaveBeenCalledTimes(1);
expect(historyQueue.add).toHaveBeenCalledTimes(1);
});
// #206 persist-6 — FIXED: a momentarily-empty live Y.Doc must not overwrite
// non-empty persisted content. `onStoreDocument` empty-guarded the LOAD path
// but not the STORE path, so an empty doc (a client/agent glitch, a bad
// merge, an emptying transclusion) was written straight over the page and the
// content was wiped silently. The store-side empty-guard now skips the write
// when the incoming doc is empty and the stored page is non-empty. A real
// intentional-clear UX is tracked separately in issue #251.
it('does NOT overwrite non-empty content with a momentarily-empty live doc (persist-6)', async () => {
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
const document = ydocFor(emptyDoc);
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: doc('IMPORTANT RICH CONTENT'),
});
await ext.onStoreDocument(buildData(document, 'user') as any);
// The empty incoming doc is rejected and the rich page survives.
expect(pageRepo.updatePage).not.toHaveBeenCalled();
// No false-success side effects for a write that never happened.
expect((document as any).broadcastStateless).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
});
// persist-6 — a legitimately-empty existing page must still be writable when
// the empty live doc actually DIFFERS from the stored content (so the
// unchanged short-circuit does NOT fire and execution reaches the empty-guard).
// This exercises the guard's third condition `!isEmptyParagraphDoc(page.content)`:
// because the stored page is ALSO empty, the guard must NOT block the write.
// The live doc normalizes to a paragraph carrying `attrs: { indent: 0 }` and no
// `content` key; the stored page is an empty paragraph with `content: []` —
// both empty per `isEmptyParagraphDoc`, but NOT `isDeepStrictEqual`, so the
// store passes the short-circuit (~line 208) and genuinely enters the guard
// (~line 229). If the `!isEmptyParagraphDoc(page.content)` condition were
// removed, the guard would block this write and updatePage would never run,
// failing this test.
it('does not block an empty store over an already-empty page (persist-6)', async () => {
const liveEmptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
const document = ydocFor(liveEmptyDoc);
// Stored content is empty per isEmptyParagraphDoc (paragraph with content:[])
// but structurally NOT deep-equal to the normalized live doc — so execution
// skips the unchanged short-circuit and reaches the empty-guard.
const storedEmptyDoc = { type: 'doc', content: [{ type: 'paragraph', content: [] }] };
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: storedEmptyDoc,
});
await ext.onStoreDocument(buildData(document, 'user') as any);
// Empty-over-empty reaches the guard, which must let the write through
// (the stored page is empty, so the empty-overwrite protection does not
// apply). updatePage IS called — proving `!isEmptyParagraphDoc(page.content)`.
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
});
// persist-1 — when every attempt fails the hook must NOT report a phantom
// success: no "page.updated" badge broadcast and no history snapshot for
// content that was never written.
it('does not run post-store side effects when every store attempt fails (persist-1)', async () => {
const document = ydocFor(doc('NEW HUMAN CONTENT'));
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN CONTENT'));
pageRepo.updatePage.mockRejectedValue(new Error('connection reset'));
await expect(
ext.onStoreDocument(buildData(document, 'user') as any),
).resolves.toBeUndefined();
// Bounded retry exhausted (MAX_STORE_ATTEMPTS).
expect(pageRepo.updatePage).toHaveBeenCalledTimes(3);
// No false-success: nothing downstream fires for the unsaved content.
expect((document as any).broadcastStateless).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
expect(aiQueue.add).not.toHaveBeenCalled();
});
});