Files
Endoleap/tests/headless/migration.test.mjs

131 lines
11 KiB
JavaScript

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';
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());
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;
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)};
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:[]});
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');
if(f.failBeforeCommit)throw Error('connection lost before commit');
const raw=JSON.parse(input.body);assert.equal(raw.request_id,input.requestId);
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;}
}
if(f.loseResponse)throw Error('connection lost after commit');
return f.alter?f.alter(structuredClone(response),input):response;
};
f.storage=storage;f.make=extra=>createCanvasMigration({scope,storage,registry,transport,isCurrentIdentity:()=>f.current,createId:()=>id(++sequence),lock:async work=>work(),...extra});
f.posts=()=>calls.filter(call=>call.method==='POST');return f;
}
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);
});
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);
});
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);
});
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);
});
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});
});
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);
}
});
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);
});
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);
});
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);
});
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);
});
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);
});