Files
Agent-System/web/src/lib/saveControlledBlockEdit.test.ts
T
weichenandCursor 9b43bd6a73 fix(web): bind editor saves to the opened doc and real PATCH
Drop test-only ETag helpers. App now calls saveControlledBlockEdit,
which PATCHes once on the bound editor docId or fail-closes. Resume
hydrate after a blocked first load, and refuse silent doc switches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 20:00:47 +00:00

208 lines
6.4 KiB
TypeScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { DocumentAST, DocumentBlock } from '../types/block';
import type { DiffResult, RevisionState } from '../features/documentRevision/types';
import {
EDITOR_DOC_MISMATCH_MESSAGE,
HEAD_CONFLICT_SAVE_MESSAGE,
STALE_BLOCK_SAVE_MESSAGE,
} from './blockSavePolicy';
import { saveControlledBlockEdit, type PatchDocumentContent } from './saveControlledBlockEdit';
function sampleAst(): DocumentAST {
return {
docId: 'doc_proj6140_intake',
title: '用户初始需求收集表',
taskKey: 'intake',
version: '1',
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-13',
revisionEtag: '1:0',
governanceMode: 'controlled',
rootBlock: {
id: 'root',
type: 'document',
content: '',
children: [
{ id: 'srv_heading', type: 'heading', level: 1, content: '标题' },
{ id: 'srv_para', type: 'paragraph', content: '正文' },
],
},
};
}
const updated: DocumentBlock = {
id: 'srv_para',
type: 'paragraph',
content: '正文【已修订】',
};
function revisionState(etag: string): RevisionState {
return {
doc_id: 'doc_proj6140_intake',
current_version_no: 1,
version_status: 'open',
baseline_snapshot_id: 1,
head_snapshot_id: 1,
head_revision_no: 1,
head_sha256: 'abc',
manual_change_count: 1,
capabilities: [],
etag,
head_block_map: [
{ id: 'srv_heading', type: 'heading', start_offset: 0, end_offset: 2, order: 0 },
{ id: 'srv_para', type: 'paragraph', start_offset: 3, end_offset: 8, order: 1 },
],
};
}
const emptyOverlay: DiffResult = {
summary: { inserts: 0, deletes: 0, modifies: 0, moves: 0 },
hunks: [],
};
describe('saveControlledBlockEdit', () => {
it('successful save calls PATCH once with bound docId, hydrate ETag, body and client_block_map', async () => {
const calls: Array<{
docId: string;
body: { markdown?: string; edit_session_id?: string; client_block_map?: { id: string }[] };
opts: { etag: string; idempotencyKey: string };
}> = [];
const patch: PatchDocumentContent = async (docId, body, opts) => {
calls.push({ docId, body, opts });
return { state: revisionState('1:1'), manual_overlay: emptyOverlay };
};
const result = await saveControlledBlockEdit(
{
editorDocId: 'doc_proj6140_intake',
activeDocId: 'doc_proj6140_intake',
ast: sampleAst(),
updated,
actorUserId: 7,
idempotencyKey: 'idem-ui06',
},
{ patchDocumentContent: patch },
);
assert.equal(calls.length, 1);
assert.equal(calls[0].docId, 'doc_proj6140_intake');
assert.equal(calls[0].opts.etag, '1:0');
assert.equal(calls[0].opts.idempotencyKey, 'idem-ui06');
assert.equal(calls[0].body.edit_session_id, 'ui-7');
assert.match(calls[0].body.markdown || '', /正文【已修订】/);
assert.deepEqual(
(calls[0].body.client_block_map || []).map((n) => n.id),
['srv_heading', 'srv_para'],
);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.closeEditor, true);
assert.equal(result.markSaved, true);
assert.equal(result.ast.revisionEtag, '1:1');
assert.equal(result.request.etag, '1:0');
assert.equal(result.request.docId, 'doc_proj6140_intake');
});
it('does not PATCH when editorDocId and activeDocId diverge', async () => {
let calls = 0;
const patch: PatchDocumentContent = async () => {
calls += 1;
return { state: revisionState('1:1'), manual_overlay: emptyOverlay };
};
const result = await saveControlledBlockEdit(
{
editorDocId: 'doc_proj6140_intake',
activeDocId: 'doc_proj6140_research',
ast: sampleAst(),
updated,
actorUserId: 7,
},
{ patchDocumentContent: patch },
);
assert.equal(calls, 0);
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.closeEditor, false);
assert.equal(result.keepEditor, true);
assert.equal(result.reason, 'doc_mismatch');
assert.equal(result.message, EDITOR_DOC_MISMATCH_MESSAGE);
});
it('does not PATCH on stale block id and keeps the draft', async () => {
let calls = 0;
const result = await saveControlledBlockEdit(
{
editorDocId: 'doc_proj6140_intake',
activeDocId: 'doc_proj6140_intake',
ast: sampleAst(),
updated: { ...updated, id: 'blk_from_modal' },
actorUserId: 7,
},
{
patchDocumentContent: async () => {
calls += 1;
return { state: revisionState('1:1'), manual_overlay: emptyOverlay };
},
},
);
assert.equal(calls, 0);
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.closeEditor, false);
assert.equal(result.message, STALE_BLOCK_SAVE_MESSAGE);
});
it('409 does not close the editor or drop the draft', async () => {
const result = await saveControlledBlockEdit(
{
editorDocId: 'doc_proj6140_intake',
activeDocId: 'doc_proj6140_intake',
ast: sampleAst(),
updated,
actorUserId: 7,
},
{
patchDocumentContent: async () => {
const err = new Error('conflict') as Error & { code?: string };
err.code = 'DOCUMENT_HEAD_CONFLICT';
throw err;
},
},
);
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.closeEditor, false);
assert.equal(result.keepEditor, true);
assert.equal(result.reason, 'head_conflict');
assert.equal(result.message, HEAD_CONFLICT_SAVE_MESSAGE);
});
it('network errors do not close the editor or drop the draft', async () => {
const result = await saveControlledBlockEdit(
{
editorDocId: 'doc_proj6140_intake',
activeDocId: 'doc_proj6140_intake',
ast: sampleAst(),
updated,
actorUserId: 7,
},
{
patchDocumentContent: async () => {
throw new Error('network down');
},
},
);
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.closeEditor, false);
assert.equal(result.keepEditor, true);
assert.equal(result.reason, 'request_failed');
assert.match(result.message, /network down/);
});
});