test(collab): cover the title-change ⋈ empty-guard intersection (F1)
The develop rebase merged the #120 title-only-change branch and the #248/#251 store-side empty-guard into one onStoreDocument. The existing 14 tests exercise each only in isolation (empty-guard tests send no title fragment; title tests send a non-empty body), so none reached the empty-guard's blocking branch with titleChanged===true. Add two paired regression tests on that exact junction: - empty body + a changed non-empty title over non-empty persisted content, no intentional-clear → the empty-guard blocks the WHOLE store, dropping the simultaneous rename too (updatePage not called); the rich content and old title survive. - the same doc with a deliberate clear armed via the real stateless transport → the empty body is allowed and the rename rides along on the same body-path updatePage (title + empty content persisted). The pair makes Test 1 non-vacuous: same doc, only the clear differs, and Test 2 proves updatePage IS reachable — so Test 1's "not called" is the guard blocking, not an unreached path. Test-only; no production change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { PersistenceExtension } from './persistence.extension';
|
||||
import { tiptapExtensions } from '../collaboration.util';
|
||||
import { buildTitleSeedYdoc, tiptapExtensions } from '../collaboration.util';
|
||||
|
||||
/**
|
||||
* Integration test for `onStoreDocument`'s Approach-A boundary snapshot.
|
||||
@@ -403,6 +404,81 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #248/#251 ⋈ title-change INTERSECTION — the empty-guard must dominate a
|
||||
// simultaneous rename. The two guards merged by the rebase are exercised in
|
||||
// isolation everywhere else: every empty-guard test sends a body with NO title
|
||||
// fragment, and every title test sends a NON-empty body. This test hits their
|
||||
// intersection: an empty body ('default') carrying a CHANGED, non-empty title
|
||||
// fragment ('New Title' over an OLD 'Old Title') landing on non-empty persisted
|
||||
// content, with NO intentional-clear. Because the body is empty,
|
||||
// bodyChanged===true, so the store goes down the BODY path (not the title-only
|
||||
// branch, which requires !bodyChanged) and reaches the empty-guard while
|
||||
// titleChanged===true. The guard must block the WHOLE store: the rename rides on
|
||||
// the same updatePage as the body, so persisting the title would also wipe the
|
||||
// body. Nothing may be written — the rich content (and old title) survive.
|
||||
it('blocks the whole store — empty body drops a simultaneous rename too (#248/#251 ⋈ title)', async () => {
|
||||
// Empty body in the 'default' fragment...
|
||||
const document = ydocFor({ type: 'doc', content: [{ type: 'paragraph' }] });
|
||||
// ...plus a CHANGED, non-empty title fragment in the SAME Y.Doc.
|
||||
Y.applyUpdate(document, Y.encodeStateAsUpdate(buildTitleSeedYdoc('New Title')));
|
||||
(document as any).broadcastStateless =
|
||||
(document as any).broadcastStateless || jest.fn();
|
||||
|
||||
// Persisted content is non-empty AND titled differently, so both
|
||||
// bodyChanged and titleChanged are true when the store runs.
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('IGNORED'),
|
||||
content: doc('IMPORTANT RICH CONTENT'),
|
||||
title: 'Old Title',
|
||||
});
|
||||
|
||||
// No intentional-clear signalled → the empty-guard blocks.
|
||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||
|
||||
// Neither the body nor the new title is written; the rich content and the
|
||||
// old title both survive. (Non-vacuity: if the guard let the rename through,
|
||||
// updatePage would be called with title:'New Title' on the body path.)
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #248/#251 ⋈ title INTERSECTION, allowed side — a deliberate clear lets BOTH
|
||||
// the empty body AND the simultaneous rename through. Same doc as above (empty
|
||||
// body + changed 'New Title'), but the clear is armed first via the REAL
|
||||
// stateless transport seam (exactly as the #251 tests do). The body path then
|
||||
// persists once, and because titleChanged===true the same updatePage carries
|
||||
// the new title — the rename rides along with the deliberate clear.
|
||||
it('allows the empty body AND the rename when an intentional clear is signalled (#248/#251 ⋈ title)', async () => {
|
||||
const documentName = `page.${PAGE_ID}`;
|
||||
const document = ydocFor({ type: 'doc', content: [{ type: 'paragraph' }] });
|
||||
Y.applyUpdate(document, Y.encodeStateAsUpdate(buildTitleSeedYdoc('New Title')));
|
||||
(document as any).broadcastStateless =
|
||||
(document as any).broadcastStateless || jest.fn();
|
||||
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('IGNORED'),
|
||||
content: doc('IMPORTANT RICH CONTENT'),
|
||||
title: 'Old Title',
|
||||
});
|
||||
|
||||
// The client signalled a deliberate clear over the live connection.
|
||||
await ext.onStateless({
|
||||
connection: { readOnly: false } as any,
|
||||
documentName,
|
||||
document: document as any,
|
||||
payload: JSON.stringify({ type: 'intentional-clear' }),
|
||||
} as any);
|
||||
|
||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||
|
||||
// The empty body was allowed and the rename rode along on the same write.
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.updatePage.mock.calls[0][0].title).toBe('New Title');
|
||||
// Pin that this went down the BODY path (not the title-only branch): the
|
||||
// persisted content is the empty doc, i.e. the deliberate clear was written.
|
||||
const expectedEmpty = TiptapTransformer.fromYdoc(document, 'default');
|
||||
expect(pageRepo.updatePage.mock.calls[0][0].content).toEqual(expectedEmpty);
|
||||
});
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user