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/); }); });