a9da8f7f15
New custom collab event applyCommentSuggestion runs replaceYjsMarkedText inside
the document's Yjs transaction on the owning instance and returns the
{ applied, currentText } verdict to the API-server caller (cross-process via the
Redis bridge, whose customEventComplete/replyId already carries handler return
values).
- withYdocConnection is now generic and returns the callback's result (captured
in a closure, since hocuspocus connection.transact does not forward it). The
callback is typed synchronous-only: transact runs fn synchronously without
awaiting, so an async fn would mutate outside the transaction and lose
atomicity.
- collaboration.gateway.handleYjsEvent: when Redis is disabled
(COLLAB_DISABLE_REDIS), dispatch the handler locally against the single
hocuspocus instance and return its verdict instead of silently returning
undefined (which would make apply a no-op). Also fixes the pre-existing silent
no-op of setCommentMark/resolveCommentMark without Redis.
Tests: handler spec (applied mutates doc + returns verdict; changed-text returns
{applied:false} without mutating; args forwarded; withYdocConnection returns the
value) and gateway spec (no-Redis path dispatches locally, returns the verdict,
not undefined).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
133 lines
4.6 KiB
TypeScript
133 lines
4.6 KiB
TypeScript
import * as Y from 'yjs';
|
|
import { CollaborationHandler } from './collaboration.handler';
|
|
import * as yjsUtil from './yjs.util';
|
|
import { User } from '@docmost/db/types/entity.types';
|
|
|
|
/**
|
|
* Unit tests for the `applyCommentSuggestion` collab handler (phase 3 of #315).
|
|
*
|
|
* The handler runs `replaceYjsMarkedText` inside the owning instance's Y
|
|
* transaction and returns the verdict to the caller. We exercise it against a
|
|
* REAL in-memory Y.Doc carrying a marked comment run, driven through a FAKE
|
|
* hocuspocus whose openDirectConnection().transact(fn) simply runs fn(doc) —
|
|
* mirroring how the real hocuspocus DirectConnection invokes the callback with
|
|
* the shared document (it does not forward the callback's return value, which is
|
|
* exactly why withYdocConnection captures it via a closure).
|
|
*/
|
|
|
|
// Build a Y.Doc with a single paragraph whose text carries a `comment` mark for
|
|
// the given commentId — the shape `replaceYjsMarkedText` walks in production.
|
|
function buildDocWithComment(text: string, commentId: string) {
|
|
const doc = new Y.Doc();
|
|
const fragment = doc.getXmlFragment('default');
|
|
const paragraph = new Y.XmlElement('paragraph');
|
|
const xmlText = new Y.XmlText();
|
|
xmlText.insert(0, text);
|
|
xmlText.format(0, text.length, { comment: { commentId, resolved: false } });
|
|
paragraph.insert(0, [xmlText]);
|
|
fragment.insert(0, [paragraph]);
|
|
return doc;
|
|
}
|
|
|
|
// Fake hocuspocus exposing only what withYdocConnection needs: a direct
|
|
// connection whose transact() runs the callback against `doc`.
|
|
function fakeHocuspocus(doc: Y.Doc) {
|
|
const connection = {
|
|
transact: jest.fn(async (fn: (d: Y.Doc) => void) => {
|
|
fn(doc);
|
|
}),
|
|
disconnect: jest.fn(async () => {}),
|
|
};
|
|
const hocuspocus = {
|
|
openDirectConnection: jest.fn(async () => connection),
|
|
} as any;
|
|
return { hocuspocus, connection };
|
|
}
|
|
|
|
const user = { id: 'u1' } as unknown as User;
|
|
|
|
describe('CollaborationHandler.applyCommentSuggestion', () => {
|
|
it('applies the replacement and returns the verdict when the marked text matches', async () => {
|
|
const doc = buildDocWithComment('Hello world', 'c1');
|
|
const { hocuspocus, connection } = fakeHocuspocus(doc);
|
|
const handler = new CollaborationHandler();
|
|
const handlers = handler.getHandlers(hocuspocus);
|
|
|
|
const result = await handlers.applyCommentSuggestion('doc-1', {
|
|
commentId: 'c1',
|
|
expectedText: 'Hello world',
|
|
newText: 'Goodbye world',
|
|
user,
|
|
});
|
|
|
|
expect(result).toEqual({ applied: true, currentText: 'Goodbye world' });
|
|
// The mutation ran inside the transaction and hit the real doc.
|
|
expect(connection.transact).toHaveBeenCalledTimes(1);
|
|
expect(connection.disconnect).toHaveBeenCalledTimes(1);
|
|
expect(doc.getXmlFragment('default').toString()).toContain(
|
|
'Goodbye world',
|
|
);
|
|
});
|
|
|
|
it('rejects (applied=false) and returns the current text when it changed', async () => {
|
|
const doc = buildDocWithComment('Hello world', 'c1');
|
|
const { hocuspocus } = fakeHocuspocus(doc);
|
|
const handler = new CollaborationHandler();
|
|
const handlers = handler.getHandlers(hocuspocus);
|
|
|
|
const result = await handlers.applyCommentSuggestion('doc-1', {
|
|
commentId: 'c1',
|
|
expectedText: 'Stale expected text',
|
|
newText: 'Goodbye world',
|
|
user,
|
|
});
|
|
|
|
expect(result).toEqual({ applied: false, currentText: 'Hello world' });
|
|
// Nothing was replaced.
|
|
expect(doc.getXmlFragment('default').toString()).toContain(
|
|
'Hello world',
|
|
);
|
|
});
|
|
|
|
it('forwards the exact args to replaceYjsMarkedText and returns its result', async () => {
|
|
const doc = buildDocWithComment('abc', 'c9');
|
|
const { hocuspocus } = fakeHocuspocus(doc);
|
|
const spy = jest
|
|
.spyOn(yjsUtil, 'replaceYjsMarkedText')
|
|
.mockReturnValue({ applied: true, currentText: 'xyz' });
|
|
const handler = new CollaborationHandler();
|
|
const handlers = handler.getHandlers(hocuspocus);
|
|
|
|
const result = await handlers.applyCommentSuggestion('doc-1', {
|
|
commentId: 'c9',
|
|
expectedText: 'abc',
|
|
newText: 'xyz',
|
|
user,
|
|
});
|
|
|
|
expect(spy).toHaveBeenCalledWith(
|
|
doc.getXmlFragment('default'),
|
|
'c9',
|
|
'abc',
|
|
'xyz',
|
|
);
|
|
expect(result).toEqual({ applied: true, currentText: 'xyz' });
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('withYdocConnection returns the callback result (transact does not forward it)', async () => {
|
|
const doc = new Y.Doc();
|
|
const { hocuspocus } = fakeHocuspocus(doc);
|
|
const handler = new CollaborationHandler();
|
|
|
|
const value = await handler.withYdocConnection(
|
|
hocuspocus,
|
|
'doc-1',
|
|
{},
|
|
() => 42,
|
|
);
|
|
|
|
expect(value).toBe(42);
|
|
});
|
|
});
|