8d8ecaed82
Agent suggestion-edits (comments with suggestedText, #315) piled up: Apply auto-resolved the thread, cluttering the resolved tab, and the anchors stayed in the document. Make them ephemeral: resolving (Apply OR the new Dismiss) makes the comment DISAPPEAR — hard-delete + remove the Yjs `comment` mark — UNLESS the thread has replies, in which case resolve it (preserve the discussion). Manual Resolve is unchanged. Scope: only comments with `suggestedText`. Server: - New collab event `deleteCommentMark` (collaboration.handler) mirroring resolveCommentMark, wiring the existing removeYjsMarkByAttribute to strip the anchor from the doc. - `finalizeAppliedSuggestion` forks on `hasChildren`: replies → apply + resolve (outcome 'resolved'); none → apply + hard-delete + mark removal (outcome 'deleted'). - New `dismissSuggestion` (validates top-level + suggestedText + not applied/not resolved) with the same fork; permission `canComment` (NOT canEdit — dismiss doesn't change page text); audit COMMENT_SUGGESTION_DISMISSED. New POST /comments/dismiss-suggestion; apply stays canEdit. - Both return `{ outcome: 'deleted' | 'resolved' }` so the client picks the optimistic action. Data-integrity (review F1): the shared `deleteEphemeralSuggestion` removes the anchor mark FIRST and FATALLY, then deletes the DB row only on success. The row delete is irreversible, so a mark-removal failure — including the COLLAB_DISABLE_REDIS "no live instance" hard-error — must abort the whole operation (→ 5xx, repeatable) rather than swallow the error and leave a permanent orphan anchor pointing at a deleted comment. `deleteCommentMark` is no longer best-effort (unlike resolve, where the row is kept and a failed mark is recoverable). Client: - `canShowDismiss` (canComment) alongside `canShowApply` (canEdit); a "Dismiss" button next to Apply in the suggestion block. - `useApplySuggestionMutation`/`useDismissSuggestionMutation` reconcile the cache on `outcome` ('deleted' → remove; 'resolved' → relocate to the resolved tab). - Idempotent races (review F2): BOTH apply and dismiss onError reduce 404/400 to success (comment already gone/resolved), dropping it from the cache instead of a red error — restores the #315 apply idempotency the ephemeral delete would otherwise break. - i18n Dismiss / "Не применять" (ru/en). Not done (flagged): deleteCommentMark on the normal /comments/delete path — left out (would change every non-suggestion delete + needs gateway injection; the interactive client already strips the mark via unsetComment). Out of scope per the issue. Tests: server — apply/dismiss delete-vs-resolve fork, all four dismiss state guards, the deleteCommentMark handler, controller authz (dismiss=canComment, apply=canEdit), AND a mark-removal-failure test proving the row is NOT deleted + the error propagates (F1). client — Dismiss show-conditions, outcome cache reconciliation, and 404 idempotent race for BOTH dismiss and apply (F2). Verified: server tsc clean; comment+collaboration jest 144 passed. client tsc clean; vitest 905 passed | 1 expected-fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
189 lines
6.6 KiB
TypeScript
189 lines
6.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);
|
|
});
|
|
});
|
|
|
|
describe('CollaborationHandler.deleteCommentMark', () => {
|
|
it('strips the comment mark for the given commentId (ephemeral suggestion #329)', async () => {
|
|
const doc = buildDocWithComment('Hello world', 'c1');
|
|
const { hocuspocus, connection } = fakeHocuspocus(doc);
|
|
const handler = new CollaborationHandler();
|
|
const handlers = handler.getHandlers(hocuspocus);
|
|
|
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
|
|
|
// The mark is gone; the text itself stays (deleting the anchor, not the run).
|
|
const xmlText = (
|
|
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
|
).get(0) as Y.XmlText;
|
|
expect(xmlText.toDelta()).toEqual([{ insert: 'Hello world' }]);
|
|
expect(connection.transact).toHaveBeenCalledTimes(1);
|
|
expect(connection.disconnect).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('routes the removal through removeYjsMarkByAttribute with the right args', async () => {
|
|
const doc = buildDocWithComment('abc', 'c9');
|
|
const { hocuspocus } = fakeHocuspocus(doc);
|
|
const spy = jest.spyOn(yjsUtil, 'removeYjsMarkByAttribute');
|
|
const handler = new CollaborationHandler();
|
|
const handlers = handler.getHandlers(hocuspocus);
|
|
|
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c9', user });
|
|
|
|
expect(spy).toHaveBeenCalledWith(
|
|
doc.getXmlFragment('default'),
|
|
'comment',
|
|
'commentId',
|
|
'c9',
|
|
);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('leaves a different comment\'s mark intact', async () => {
|
|
const doc = buildDocWithComment('keep me', 'other');
|
|
const { hocuspocus } = fakeHocuspocus(doc);
|
|
const handler = new CollaborationHandler();
|
|
const handlers = handler.getHandlers(hocuspocus);
|
|
|
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
|
|
|
const xmlText = (
|
|
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
|
).get(0) as Y.XmlText;
|
|
expect(xmlText.toDelta()).toEqual([
|
|
{
|
|
insert: 'keep me',
|
|
attributes: { comment: { commentId: 'other', resolved: false } },
|
|
},
|
|
]);
|
|
});
|
|
});
|