Login-seeded doc-001 is not server-backed; align shouldLoad with revisionToolbarMountMode so content and revision-state are never requested. Co-authored-by: Cursor <cursoragent@cursor.com>
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
canonicalDocIdForTask,
|
|
controlledHydrateFingerprint,
|
|
hydrateControlledDocumentContent,
|
|
resolveCanonicalDocId,
|
|
shouldLoadControlledContent,
|
|
} from './controlledDocHydrate';
|
|
import {
|
|
revisionToolbarMountMode,
|
|
shouldMountDocumentRevisionToolbar,
|
|
} from '../features/documentRevision/revisionToolbarMount';
|
|
import { resolvePatchEtag } from './revisionEtag';
|
|
import { createEditorHydrationGate } from './editorHydrationGate';
|
|
import type { DeliverableItem } from '../types/workbench';
|
|
|
|
const base: DeliverableItem = {
|
|
docId: 'doc_proj56_intake',
|
|
projectId: 56,
|
|
taskKey: 'intake',
|
|
title: '用户初始需求收集表',
|
|
stage: 'V0.5',
|
|
category: 'workflow',
|
|
creatorId: 1,
|
|
creatorName: '张工',
|
|
isFixedWorkflow: true,
|
|
isTemplateOnly: false,
|
|
lifecycleState: 'drafting',
|
|
isClaimed: true,
|
|
author: '张工',
|
|
reviewer: '李主管',
|
|
approver: '王总监',
|
|
updatedAt: '2026-09-11',
|
|
governanceMode: 'controlled',
|
|
};
|
|
|
|
describe('controlledDocHydrate', () => {
|
|
it('shouldLoadControlledContent when non-template workflow doc', () => {
|
|
assert.equal(shouldLoadControlledContent(base), true);
|
|
assert.equal(shouldLoadControlledContent({ ...base, isTemplateOnly: true }), false);
|
|
});
|
|
|
|
it('shouldLoadControlledContent rejects local legacy/mock ids like doc-001', () => {
|
|
const local = { ...base, docId: 'doc-001', projectId: 1, taskKey: 'feasibility_reg' };
|
|
assert.equal(shouldLoadControlledContent(local), false);
|
|
assert.equal(shouldLoadControlledContent({ ...base, docId: 'doc-002' }), false);
|
|
assert.equal(shouldLoadControlledContent({ ...base, docId: 'doc-shared-1' }), false);
|
|
assert.equal(shouldLoadControlledContent({ ...base, docId: 'doc-private-01' }), false);
|
|
assert.equal(shouldLoadControlledContent({ ...base, docId: 'doc-cusa-feasibility-reg' }), false);
|
|
});
|
|
|
|
it('shouldLoadControlledContent keeps canonical, freeform-off, and template preview rules', () => {
|
|
assert.equal(shouldLoadControlledContent(base), true);
|
|
assert.equal(
|
|
shouldLoadControlledContent({
|
|
...base,
|
|
docId: 'doc-p143-intake',
|
|
isTemplateOnly: true,
|
|
taskKey: 'intake',
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
shouldLoadControlledContent({
|
|
...base,
|
|
docId: 'doc-free-1789263145197-2',
|
|
governanceMode: 'freeform',
|
|
taskKey: 'freeform_doc',
|
|
isFixedWorkflow: false,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
shouldLoadControlledContent({
|
|
...base,
|
|
docId: 'doc-upload-1789263145197-0',
|
|
sourceType: 'uploaded_file',
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('hydrateControlledDocumentContent never requests content/revision for doc-001', async () => {
|
|
let contentCalls = 0;
|
|
let revisionCalls = 0;
|
|
const ast = await hydrateControlledDocumentContent(
|
|
'doc-001',
|
|
{ ...base, docId: 'doc-001' },
|
|
{
|
|
fetchContent: async () => {
|
|
contentCalls += 1;
|
|
throw new Error('must not fetch content for local mock');
|
|
},
|
|
fetchRevisionState: async () => {
|
|
revisionCalls += 1;
|
|
throw new Error('must not fetch revision-state for local mock');
|
|
},
|
|
},
|
|
);
|
|
assert.equal(ast, null);
|
|
assert.equal(contentCalls, 0);
|
|
assert.equal(revisionCalls, 0);
|
|
});
|
|
|
|
it('hydrateControlledDocumentContent never requests for template preview or registered freeform', async () => {
|
|
const forbidden = {
|
|
fetchContent: async () => {
|
|
throw new Error('must not fetch content');
|
|
},
|
|
fetchRevisionState: async () => {
|
|
throw new Error('must not fetch revision-state');
|
|
},
|
|
};
|
|
const template = await hydrateControlledDocumentContent(
|
|
'doc-p143-intake',
|
|
{ ...base, docId: 'doc-p143-intake', isTemplateOnly: true, taskKey: 'intake' },
|
|
forbidden,
|
|
);
|
|
const freeform = await hydrateControlledDocumentContent(
|
|
'doc-free-1789263145197-2',
|
|
{
|
|
...base,
|
|
docId: 'doc-free-1789263145197-2',
|
|
governanceMode: 'freeform',
|
|
taskKey: 'freeform_doc',
|
|
isFixedWorkflow: false,
|
|
},
|
|
forbidden,
|
|
);
|
|
assert.equal(template, null);
|
|
assert.equal(freeform, null);
|
|
});
|
|
|
|
it('shouldLoadControlledContent stays inside toolbar server-backed eligibility', () => {
|
|
const samples: DeliverableItem[] = [
|
|
base,
|
|
{ ...base, docId: 'doc-001', projectId: 1, taskKey: 'feasibility_reg' },
|
|
{ ...base, docId: 'doc-p143-intake', isTemplateOnly: true, taskKey: 'intake' },
|
|
{
|
|
...base,
|
|
docId: 'doc-free-1789263145197-2',
|
|
governanceMode: 'freeform',
|
|
taskKey: 'freeform_doc',
|
|
isFixedWorkflow: false,
|
|
},
|
|
{ ...base, docId: 'doc-upload-1789263145197-0', sourceType: 'uploaded_file' },
|
|
];
|
|
for (const doc of samples) {
|
|
const load = shouldLoadControlledContent(doc);
|
|
const mount = shouldMountDocumentRevisionToolbar(doc);
|
|
if (load) {
|
|
assert.equal(mount, true, `${doc.docId} loaded without toolbar eligibility`);
|
|
assert.equal(revisionToolbarMountMode(doc), 'toolbar');
|
|
}
|
|
if (doc.docId === 'doc-001') {
|
|
assert.equal(load, false);
|
|
assert.equal(mount, false);
|
|
assert.equal(revisionToolbarMountMode(doc), 'local_hint');
|
|
}
|
|
}
|
|
});
|
|
|
|
it('resolveCanonicalDocId maps doc-p56-intake to doc_proj56_intake', () => {
|
|
assert.equal(resolveCanonicalDocId('doc-p56-intake', [base]), 'doc_proj56_intake');
|
|
});
|
|
|
|
it('canonicalDocIdForTask maps doc-p56-research to doc_proj56_research', () => {
|
|
assert.equal(
|
|
canonicalDocIdForTask(56, 'research', undefined, 'doc-p56-research'),
|
|
'doc_proj56_research',
|
|
);
|
|
});
|
|
|
|
it('attaches hydrate ETag as PATCH baseline and ignores a later GET', async () => {
|
|
const ast = await hydrateControlledDocumentContent('doc_proj56_intake', base, {
|
|
fetchContent: async () => ({
|
|
doc_id: 'doc_proj56_intake',
|
|
version: 1,
|
|
content_sha256: 'abc',
|
|
markdown: '# 标题\n\n正文',
|
|
is_template_only: false,
|
|
}),
|
|
fetchRevisionState: async () => ({ etag: '2:7' }),
|
|
});
|
|
assert.ok(ast);
|
|
assert.equal(ast?.revisionEtag, '2:7');
|
|
const latestGet = { etag: '2:8' };
|
|
const patchEtag = resolvePatchEtag({
|
|
revisionEtag: ast?.revisionEtag,
|
|
governanceMode: 'controlled',
|
|
});
|
|
assert.equal(patchEtag, '2:7');
|
|
assert.notEqual(patchEtag, latestGet.etag);
|
|
});
|
|
|
|
it('rejects hydrate when revision-state network or 5xx fails', async () => {
|
|
await assert.rejects(
|
|
() =>
|
|
hydrateControlledDocumentContent('doc_proj56_intake', base, {
|
|
fetchContent: async () => ({
|
|
doc_id: 'doc_proj56_intake',
|
|
version: 1,
|
|
content_sha256: 'abc',
|
|
markdown: '# 标题\n\n正文',
|
|
is_template_only: false,
|
|
}),
|
|
fetchRevisionState: async () => {
|
|
const err = new Error('revision-state unavailable') as Error & { status?: number };
|
|
err.status = 502;
|
|
throw err;
|
|
},
|
|
}),
|
|
(err: Error & { status?: number }) => err.status === 502,
|
|
);
|
|
});
|
|
|
|
it('aligns markdownToBlockAST random ids onto revision-state head_block_map', async () => {
|
|
const markdown = '# 标题\n\n正文';
|
|
const head_block_map = [
|
|
{ id: 'srv_heading_intake', type: 'heading', start_offset: 0, end_offset: 4, order: 0 },
|
|
{ id: 'srv_para_intake', type: 'paragraph', start_offset: 5, end_offset: 7, order: 1 },
|
|
];
|
|
const hydrate = () =>
|
|
hydrateControlledDocumentContent('doc_proj56_intake', base, {
|
|
fetchContent: async () => ({
|
|
doc_id: 'doc_proj56_intake',
|
|
version: 1,
|
|
content_sha256: 'abc',
|
|
markdown,
|
|
is_template_only: false,
|
|
}),
|
|
fetchRevisionState: async () => ({ etag: '1:0', head_block_map }),
|
|
});
|
|
const first = await hydrate();
|
|
const second = await hydrate();
|
|
assert.ok(first && second);
|
|
const firstIds = first.rootBlock.children?.map((c) => c.id);
|
|
const secondIds = second.rootBlock.children?.map((c) => c.id);
|
|
assert.deepEqual(firstIds, ['srv_heading_intake', 'srv_para_intake']);
|
|
assert.deepEqual(secondIds, firstIds);
|
|
});
|
|
|
|
it('leaves Math.random ids when head_block_map is omitted (why gate + fail-closed remain)', async () => {
|
|
const markdown = '# 标题\n\n正文';
|
|
const hydrate = () =>
|
|
hydrateControlledDocumentContent('doc_proj56_intake', base, {
|
|
fetchContent: async () => ({
|
|
doc_id: 'doc_proj56_intake',
|
|
version: 1,
|
|
content_sha256: 'abc',
|
|
markdown,
|
|
is_template_only: false,
|
|
}),
|
|
fetchRevisionState: async () => ({ etag: '1:0' }),
|
|
});
|
|
const first = await hydrate();
|
|
const second = await hydrate();
|
|
assert.ok(first && second);
|
|
const firstIds = first.rootBlock.children?.map((c) => c.id) ?? [];
|
|
const secondIds = second.rootBlock.children?.map((c) => c.id) ?? [];
|
|
assert.equal(firstIds.length, 2);
|
|
assert.equal(secondIds.length, 2);
|
|
assert.notDeepEqual(firstIds, secondIds);
|
|
});
|
|
|
|
it('rejects controlled hydrate on NOT_FOUND or NEEDS_CONTENT', async () => {
|
|
const missingLedger = async () => {
|
|
const err = new Error('needs content') as Error & { code?: string };
|
|
err.code = 'DOCUMENT_REVISION_NEEDS_CONTENT';
|
|
throw err;
|
|
};
|
|
await assert.rejects(
|
|
() =>
|
|
hydrateControlledDocumentContent('doc_proj56_intake', base, {
|
|
fetchContent: async () => ({
|
|
doc_id: 'doc_proj56_intake',
|
|
version: 1,
|
|
content_sha256: 'abc',
|
|
markdown: '# 标题\n\n正文',
|
|
is_template_only: false,
|
|
}),
|
|
fetchRevisionState: missingLedger,
|
|
}),
|
|
(err: Error & { code?: string }) => err.code === 'DOCUMENT_REVISION_NEEDS_CONTENT',
|
|
);
|
|
});
|
|
|
|
it('fingerprint stays stable when only object identity changes', () => {
|
|
assert.equal(controlledHydrateFingerprint(base), controlledHydrateFingerprint({ ...base }));
|
|
assert.notEqual(
|
|
controlledHydrateFingerprint(base),
|
|
controlledHydrateFingerprint({ ...base, updatedAt: '2026-09-14' }),
|
|
);
|
|
});
|
|
|
|
it('dedupes in-flight hydrate for the same doc fingerprint', async () => {
|
|
let calls = 0;
|
|
let release!: () => void;
|
|
const hold = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const fetchContent = async () => {
|
|
calls += 1;
|
|
await hold;
|
|
return {
|
|
doc_id: 'doc_proj56_intake',
|
|
version: 1,
|
|
content_sha256: 'abc',
|
|
markdown: '# 标题\n\n正文',
|
|
is_template_only: false,
|
|
};
|
|
};
|
|
const fetchRevisionState = async () => ({ etag: '1:0' });
|
|
const a = hydrateControlledDocumentContent('doc_proj56_intake', base, {
|
|
fetchContent,
|
|
fetchRevisionState,
|
|
});
|
|
const b = hydrateControlledDocumentContent('doc_proj56_intake', { ...base }, {
|
|
fetchContent,
|
|
fetchRevisionState,
|
|
});
|
|
release();
|
|
await Promise.all([a, b]);
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
it('aligned late hydrate still cannot overwrite a PATCH-success baseline', async () => {
|
|
const markdown = '# 标题\n\n正文';
|
|
const head_block_map = [
|
|
{ id: 'srv_heading_intake', type: 'heading', start_offset: 0, end_offset: 4, order: 0 },
|
|
{ id: 'srv_para_intake', type: 'paragraph', start_offset: 5, end_offset: 7, order: 1 },
|
|
];
|
|
let release!: (value: 'go') => void;
|
|
const hold = new Promise<'go'>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const gate = createEditorHydrationGate();
|
|
gate.setActiveDocId('doc_proj56_intake');
|
|
const token = gate.beginHydration('doc_proj56_intake');
|
|
assert.ok(token);
|
|
|
|
const pending = hydrateControlledDocumentContent('doc_proj56_intake', base, {
|
|
fetchContent: async () => {
|
|
await hold;
|
|
return {
|
|
doc_id: 'doc_proj56_intake',
|
|
version: 1,
|
|
content_sha256: 'abc',
|
|
markdown,
|
|
is_template_only: false,
|
|
};
|
|
},
|
|
fetchRevisionState: async () => ({ etag: '1:0', head_block_map }),
|
|
});
|
|
|
|
gate.setEditorOpen(true);
|
|
gate.markSaved();
|
|
gate.setEditorOpen(false);
|
|
release('go');
|
|
const ast = await pending;
|
|
assert.ok(ast);
|
|
assert.deepEqual(ast.rootBlock.children?.map((c) => c.id), [
|
|
'srv_heading_intake',
|
|
'srv_para_intake',
|
|
]);
|
|
assert.deepEqual(gate.decide(token), { apply: false, reason: 'superseded_by_save' });
|
|
});
|
|
});
|