Files

158 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧快照到 canonical 身份迁移的持久请求状态机测试。先 drain 旧写、读取最新源版本、保存冻结 body,再派发迁移。
* 未知结果保留原 ID/字节,恢复先查询后按原请求重试;只有验证过的终态才可清理恢复材料或放开旧编辑。
* 内存存储与假服务端注入响应丢失、版本冲突及配额问题;真实 UI/Web Locks 场景由 renderer 集成探针另测。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
import {createCanvasMigration,readCanvasMigration,canvasMigrationStorageKey,migrationBlocksCanvas} from '../../src/lib/canvas-migration.ts';
// 固定 UUID 作用域及新元素身份;源旧 ID 为 a,成功迁移须返回声明映射到新元素的证明。
const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,root=id(1),child=id(2),scope={accountId:id(3),workspaceId:id(4)},registry=createRegistry(builtinComponents());
// 保存迁移字符串、回执、冻结 body 和请求记录,开关区分提交前失败、提交后丢 ACK、明确拒绝和篡改响应。
function fixture(){
const records=new Map(),receipts=new Map(),bodies=new Map(),calls=[];
const f={records,receipts,calls,version:1,canonical:false,current:true,quota:false,loseResponse:false,failBeforeCommit:false,reject:false,alter:undefined};let sequence=100;
// 模拟 localStorage;quota 在 setItem 前抛错,removeItem 真正删除恢复记录。
const storage={getItem:key=>records.get(key)??null,setItem(key,value){if(f.quota)throw Error('quota');records.set(key,value);},removeItem:key=>records.delete(key)};
// 完整 canonical 模型与新身份 id(20),供成功回执的版本、元素类型和 identity_map 交叉校验。
const model=version=>({documentFormatVersion:1,projectId:root,complete:true,canvases:[{id:root,parent:null}],elements:[{id:id(20),incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:'a',text:'body'},geometry:{x:0,y:0,width:240,height:72,rotation:0}}],versions:{[root]:version},tombstones:{},removedCanvasIds:[]});
// GET 分别查迁移需求/完整模型和原回执;POST 先断言已持久化同一 body,再按 requestId 防止重复应用。
const transport=async input=>{
calls.push(input);
if(input.method==='GET'&&input.path.endsWith('/model'))return f.canonical?{status:200,body:{model:model(f.version)}}:{status:409,body:{error:'canonical_migration_required',document_format_version:1,root_id:root,source_versions:{[root]:f.version}}};
if(input.method==='GET'){
const key=input.path.split('/').at(-1),known=receipts.get(key);
const response=known?{status:200,body:known.body}:{status:404,body:{status:'unknown',request_id:key,error:'operation_receipt_unknown'}};
return f.alter?f.alter(response,input):response;
}
// 网络派发前直接读取保存字符串,对照请求体,证明持久化屏障而非只检查内存中存在请求。
assert.equal(JSON.parse(storage.getItem(canvasMigrationStorageKey(scope))).body,input.body,'request must be durable before POST');
assert.equal(input.responseFormat,'canvas-v2');
// 提交前失败没有服务端回执;恢复只能查询 unknown 后精确重试,不能据传输异常推定 applied。
if(f.failBeforeCommit)throw Error('connection lost before commit');
const raw=JSON.parse(input.body);assert.equal(raw.request_id,input.requestId);
// 已知 key 必须使用相同冻结字节;新 key 根据 reject 开关生成可信零写入拒绝或带映射/完整模型的成功。
let response=receipts.get(input.requestId);
if(response){assert.equal(bodies.get(input.requestId),input.body,'retry changed frozen bytes');}
else{
response=f.reject?{status:412,body:{protocol_version:2,request_id:input.requestId,status:'not-applied',terminal:true,error:'migration_version_conflict'}}:{status:200,body:{protocol_version:2,request_id:input.requestId,status:'applied',source_versions:raw.expected_versions,identity_map:[{sourceCanvasId:root,structure:'node',sourceId:'a',elementId:id(20)}],model:model(f.version+1)}};
receipts.set(input.requestId,response);bodies.set(input.requestId,input.body);
if(!f.reject){f.version++;f.canonical=true;}
}
// 此时服务端事实与回执已保存,再模拟 ACK 丢失;客户端看到错误也不能生成另一次迁移。
if(f.loseResponse)throw Error('connection lost after commit');
return f.alter?f.alter(structuredClone(response),input):response;
};
// 组装迁移控制器,默认 lock 只执行 work(不证明真实互斥);current、createId 和 signal 可被用例注入。
f.storage=storage;f.make=extra=>createCanvasMigration({scope,storage,registry,transport,isCurrentIdentity:()=>f.current,createId:()=>id(++sequence),lock:async work=>work(),...extra});
// 从全部调用中过滤 POST,分别检查旧请求精确重试与明确重新准备产生新请求。
f.posts=()=>calls.filter(call=>call.method==='POST');return f;
}
// drain 必须在任何读请求前完成,之后重新读源版本;POST 使用 drain 后的版本和预先持久化 requestId,成功后清理记录。
test('migration drains first, refreshes source versions and persists exact request identity before dispatch',async()=>{
const f=fixture(),migration=f.make();let drained=false;
const result=await migration.start(root,async()=>{assert.equal(f.calls.length,0);drained=true;f.version=4;});
assert.equal(drained,true);assert.equal(result.status,'applied');assert.equal(result.rootId,root);
assert.deepEqual(JSON.parse(f.posts()[0].body).expected_versions,{[root]:4});assert.equal(migration.read(),undefined);
assert.equal(f.posts()[0].requestId,JSON.parse(f.posts()[0].body).request_id);
});
// 服务提交成功但响应丢失,重建控制器先查原 key 即恢复成功;不再次 drain、不分配新 ID,也不发第二个 POST。
test('lost committed migration ACK is recovered by original query after reload without flushing or invoking twice',async()=>{
const f=fixture();f.loseResponse=true;const first=f.make();
assert.equal((await first.start(root,async()=>{})).status,'pending');const job=first.read();
assert.equal(migrationBlocksCanvas(job,root),true);assert.equal(migrationBlocksCanvas(job,child),false);
f.loseResponse=false;const recovered=f.make({createId(){assert.fail('recovery must not allocate a new key');}});
assert.equal((await recovered.start(root,async()=>assert.fail('old writers must remain frozen'))).status,'applied');
assert.equal(f.posts().length,1);assert.ok(f.calls.some(call=>call.path.endsWith('/operations/'+job.id)));assert.equal(recovered.read(),undefined);
});
// 提交前断线的 durable 请求先 query unknown,再保留原 ID 和包含额外空白的 body 精确重试,不能重序列化。
test('a durable uncommitted request queries unknown then retries only its exact original body and key',async()=>{
const f=fixture();f.failBeforeCommit=true;const first=f.make();await first.start(root,async()=>{});
const original=first.read(),spaced={...original,body:' \n'+original.body+' '};f.storage.setItem(canvasMigrationStorageKey(scope),JSON.stringify(spaced));f.failBeforeCommit=false;
const recovered=f.make({createId(){assert.fail('must retain original key');}});
assert.equal((await recovered.resume()).status,'applied');
assert.equal(f.posts()[1].requestId,original.id);assert.equal(f.posts()[1].body,spaced.body);
assert.equal(f.calls.at(-2).method,'GET');assert.equal(f.calls.at(-2).body,undefined);
});
// 存储配额或旧 writer drain 失败均不得发迁移;drain 失败也不能留下新恢复任务或触发网络读取。
test('storage and legacy-drain failure prevent dispatch and do not silently discard recovery state',async()=>{
const f=fixture();f.quota=true;
await assert.rejects(()=>f.make().start(root,async()=>{}),/quota/);assert.equal(f.posts().length,0);
f.quota=false;f.calls.length=0;
await assert.rejects(()=>f.make().start(root,async()=>{throw Error('draft_recovery_required');}),/draft_recovery_required/);
assert.equal(f.calls.length,0);assert.equal(f.records.size,0);
});
// 可信 terminal not-applied 才将 phase 置 rejected 并释放编辑 gate;resume 不重发,显式 start 才刷新版本并生成新 key。
test('only verified terminal rejection releases the editing gate; a new explicit preparation refreshes and allocates',async()=>{
const f=fixture();f.reject=true;const migration=f.make();const result=await migration.start(root,async()=>{});
assert.equal(result.status,'rejected');const rejected=migration.read();assert.equal(rejected.phase,'rejected');assert.equal(migrationBlocksCanvas(rejected,root),false);
const posts=f.posts().length;assert.equal((await migration.resume()).status,'rejected');assert.equal(f.posts().length,posts);
f.reject=false;f.version=7;
assert.equal((await migration.start(root,async()=>{})).status,'applied');assert.notEqual(f.posts()[1].requestId,rejected.id);assert.deepEqual(JSON.parse(f.posts()[1].body).expected_versions,{[root]:7});
});
// 裸 403、错 key/源版本/空或错类型/跨 canvas 映射不构成成功证明,保持 pending gate;原回执查询修复后仍只有一次 POST。
test('bare rejection and malformed or foreign success retain the frozen request until the original query repairs it',async()=>{
for(const alter of [
response=>({status:403,body:{error:'forbidden'}}),
response=>({...response,body:{...response.body,request_id:id(999)}}),
response=>({...response,body:{...response.body,source_versions:{[root]:99}}}),
response=>({...response,body:{...response.body,identity_map:[]}}),
response=>({...response,body:{...response.body,identity_map:[{sourceCanvasId:child,structure:'node',sourceId:'a',elementId:id(20)}]}}),
response=>({...response,body:{...response.body,identity_map:[{sourceCanvasId:root,structure:'connection',sourceId:'a',elementId:id(20)}]}}),
]){
const f=fixture();f.alter=alter;const migration=f.make();assert.equal((await migration.start(root,async()=>{})).status,'pending');
const frozen=migration.read();assert.equal(frozen.phase,'pending');assert.equal(migrationBlocksCanvas(frozen,root),true);
f.alter=undefined;assert.equal((await f.make().resume()).status,'applied');assert.equal(f.posts().length,1);
}
});
// 未来迁移格式及同 workspace 另一 pending 根不能被覆盖;换账户读取不同存储键,不泄漏旧请求。
test('future/corrupt migration material and another pending project cannot be overwritten',async()=>{
const f=fixture(),key=canvasMigrationStorageKey(scope);f.storage.setItem(key,JSON.stringify({version:2,payload:'retain'}));
const saved=f.storage.getItem(key);await assert.rejects(()=>f.make().start(root,async()=>assert.fail()),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.calls.length,0);
f.storage.removeItem(key);f.failBeforeCommit=true;const migration=f.make();await migration.start(root,async()=>{});const pending=f.storage.getItem(key);
await assert.rejects(()=>migration.start(child,async()=>assert.fail()),/workspace_migration_pending/);assert.equal(f.storage.getItem(key),pending);
assert.equal(readCanvasMigration(f.storage,{...scope,accountId:id(99)}),undefined);
});
// 响应回来时身份已变或 signal abort,不能清理旧账户恢复材料;回到有效身份后查询原回执,不重复迁移。
test('identity changes and aborted lifetimes cannot clear an old account request on a late success',async()=>{
const f=fixture();f.alter=response=>{f.current=false;return response;};
await assert.rejects(()=>f.make().start(root,async()=>{}),/identity_changed/);assert.equal(f.records.size,1);
f.current=true;f.alter=undefined;assert.equal((await f.make().resume()).status,'applied');assert.equal(f.posts().length,1);
const g=fixture(),controller=new AbortController();g.alter=response=>{controller.abort();return response;};
await assert.rejects(()=>g.make({signal:controller.signal}).start(root,async()=>{}),{name:'AbortError'});assert.equal(g.records.size,1);assert.equal(g.posts()[0].signal,controller.signal);
});
// 同控制器重入 resume 被 migration_busy 拒绝;完成后新控制器识别已 canonical,只返回 already-migrated 不再 POST。
test('reentrant actions cannot create a second in-flight migration and already migrated projects do not POST',async()=>{
const f=fixture();let entered,release;const started=new Promise(resolve=>entered=resolve),gate=new Promise(resolve=>release=resolve);
const original=f.make();
const calls=f.calls;const migration=f.make({lock:async work=>{entered();await gate;return work();}});
const first=migration.start(root,async()=>{});await started;
await assert.rejects(()=>migration.resume(),/migration_busy/);assert.equal(calls.length,0);release();assert.equal((await first).status,'applied');
assert.equal((await original.start(root,async()=>{})).status,'already-migrated');assert.equal(f.posts().length,1);
});
// 成功后清理记录失败仍保留可恢复请求,重试查询即可;假存储吞掉 setItem 时以 migration_not_durable 拒绝派发。
test('failed result retirement remains recoverable and a storage port that drops writes cannot dispatch',async()=>{
const f=fixture(),storage={...f.storage,removeItem(){throw Error('storage unavailable');}};
await assert.rejects(()=>f.make({storage}).start(root,async()=>{}),/storage unavailable/);
assert.equal(f.records.size,1);assert.equal((await f.make().resume()).status,'applied');assert.equal(f.posts().length,1);
const g=fixture();await assert.rejects(()=>g.make({storage:{...g.storage,setItem(){}}}).start(root,async()=>{}),/migration_not_durable/);assert.equal(g.posts().length,0);
});
// 合法空模型允许 identity_map 为空,不应为了满足校验凭空生成不存在元素的映射。
test('a valid empty migration has no fabricated identity entries',async()=>{
const f=fixture();f.alter=response=>({...response,body:{...response.body,identity_map:[],model:{...response.body.model,elements:[]}}});
assert.equal((await f.make().start(root,async()=>{})).status,'applied');assert.equal(f.records.size,0);
});