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>
208 lines
6.4 KiB
TypeScript
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/);
|
|
});
|
|
});
|