165 lines
7.4 KiB
Python
165 lines
7.4 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
SYNC = Path(__file__).parents[1] / "frontend" / "unfiled-draft-sync.js"
|
|
|
|
|
|
def run_node(script: str):
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_unfiled_draft_sync_moves_capture_between_devices_and_propagates_delete():
|
|
script = f"""
|
|
const createSync=require({json.dumps(str(SYNC))});
|
|
let remote={{revision:0,drafts:[]}};
|
|
function api(url,init={{}}) {{
|
|
if (!init.method) return Promise.resolve(JSON.parse(JSON.stringify(remote)));
|
|
const body=JSON.parse(init.body);
|
|
if (body.revision!==remote.revision) {{
|
|
const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};return Promise.reject(error);
|
|
}}
|
|
remote={{revision:remote.revision+1,drafts:body.drafts}};
|
|
return Promise.resolve(JSON.parse(JSON.stringify(remote)));
|
|
}}
|
|
function device(initial=[]) {{
|
|
let drafts=initial.map(item=>({{...item}})); const values=new Map(); const states=[];
|
|
const captures={{
|
|
exportOwned:async()=>drafts.map(item=>({{...item}})),
|
|
mergeRemote:async(items)=>{{let count=0;for(const item of items)if(!drafts.some(x=>x.id===item.id)){{drafts.push({{...item}});count++}}return count}},
|
|
discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}},
|
|
}};
|
|
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
|
const sync=createSync({{captures,fetchJson:api,storage,onState:state=>states.push(state)}});
|
|
return {{sync,states,drafts:()=>drafts}};
|
|
}}
|
|
(async()=>{{
|
|
const phone=device([{{id:'field',title:'Field failure',body:'Steps',saved_at:42,evidence:[{{filename:'proof.png',content_type:'image/png',data:'cHJvb2Y='}}]}}]);
|
|
await phone.sync.sync('timmy');
|
|
const desktop=device();
|
|
await desktop.sync.sync('timmy');
|
|
await desktop.sync.remove('field','timmy');
|
|
await phone.sync.sync('timmy');
|
|
process.stdout.write(JSON.stringify({{
|
|
remote,desktop:desktop.drafts(),phone:phone.drafts(),
|
|
phoneStates:phone.states.map(x=>x.status),desktopStates:desktop.states.map(x=>x.status),
|
|
}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["remote"]["revision"] == 2
|
|
assert output["remote"]["drafts"] == []
|
|
assert output["desktop"] == []
|
|
assert output["phone"] == []
|
|
assert output["phoneStates"][-1] == "ready"
|
|
assert output["desktopStates"][-1] == "ready"
|
|
|
|
|
|
def test_unfiled_draft_sync_surfaces_revision_conflict_without_losing_local_draft():
|
|
script = f"""
|
|
const createSync=require({json.dumps(str(SYNC))});
|
|
let calls=0;const states=[];let drafts=[{{id:'local',title:'Local',body:'',saved_at:1}}];
|
|
const conflict={{revision:2,drafts:[{{id:'remote',title:'Remote',body:'',saved_at:2}}]}};
|
|
const sync=createSync({{
|
|
captures:{{exportOwned:async()=>drafts,mergeRemote:async()=>0,discard:async()=>true}},
|
|
storage:{{getItem:()=>null,setItem:()=>{{}}}},
|
|
fetchJson:async(_url,init)=>{{calls++;if(!init)return {{revision:1,drafts:[]}};const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:conflict}}}};throw error}},
|
|
onState:state=>states.push(state),
|
|
}});
|
|
(async()=>{{let message='';try{{await sync.sync('timmy')}}catch(error){{message=error.message}}process.stdout.write(JSON.stringify({{calls,states,message,drafts}}))}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["calls"] == 2
|
|
assert output["states"][-1]["status"] == "conflict"
|
|
assert output["states"][-1]["message"] == "Drafts changed on another device. Sync again to combine them."
|
|
assert output["drafts"][0]["id"] == "local"
|
|
|
|
|
|
def test_unfiled_draft_sync_coalesces_overlapping_reconnects_without_self_conflict():
|
|
script = f"""
|
|
const createSync=require({json.dumps(str(SYNC))});
|
|
let remote={{revision:0,drafts:[]}}, gets=0, puts=0, release;
|
|
const gate=new Promise(resolve=>release=resolve);
|
|
const api=async(_url,init)=>{{
|
|
if(!init){{gets++;if(gets===1)await gate;return JSON.parse(JSON.stringify(remote))}}
|
|
puts++;const body=JSON.parse(init.body);
|
|
if(body.revision!==remote.revision){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error}}
|
|
remote={{revision:remote.revision+1,drafts:body.drafts}};return remote;
|
|
}};
|
|
const draft={{id:'local',title:'Local',body:'',saved_at:1}};
|
|
const sync=createSync({{
|
|
captures:{{exportOwned:async()=>[draft],mergeRemote:async()=>0,discard:async()=>true}},
|
|
storage:{{getItem:()=>null,setItem:()=>{{}}}},fetchJson:api,
|
|
}});
|
|
(async()=>{{const first=sync.sync('timmy'),second=sync.sync('timmy');release();const results=await Promise.all([first,second]);process.stdout.write(JSON.stringify({{gets,puts,results,remote}}))}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["puts"] == 1
|
|
assert output["remote"]["drafts"][0]["id"] == "local"
|
|
assert output["results"] == [True, True]
|
|
|
|
|
|
def test_unfiled_draft_sync_preserves_divergent_same_draft_edits_as_a_conflict_copy():
|
|
script = f"""
|
|
const createSync=require({json.dumps(str(SYNC))});
|
|
let remote={{revision:0,drafts:[]}};
|
|
async function api(_url,init) {{
|
|
if(!init) return JSON.parse(JSON.stringify(remote));
|
|
const body=JSON.parse(init.body);
|
|
if(body.revision!==remote.revision) {{
|
|
const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error;
|
|
}}
|
|
remote={{revision:remote.revision+1,drafts:body.drafts}};
|
|
return JSON.parse(JSON.stringify(remote));
|
|
}}
|
|
function device(initial=[]) {{
|
|
let drafts=JSON.parse(JSON.stringify(initial));
|
|
const values=new Map(), states=[];
|
|
const captures={{
|
|
exportOwned:async()=>JSON.parse(JSON.stringify(drafts)),
|
|
mergeRemote:async items=>{{for(const item of items)if(!drafts.some(x=>x.id===item.id))drafts.push(JSON.parse(JSON.stringify(item))) }},
|
|
reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}},
|
|
discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}},
|
|
}};
|
|
const sync=createSync({{
|
|
captures,fetchJson:api,
|
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
|
createConflictId:()=> 'field-conflict-phone',onState:state=>states.push(state),
|
|
}});
|
|
return {{sync,states,edit(body,saved_at){{drafts[0].body=body;drafts[0].saved_at=saved_at}},drafts:()=>drafts}};
|
|
}}
|
|
(async()=>{{
|
|
const original={{id:'field',title:'Field failure',body:'original',saved_at:1}};
|
|
const phone=device([original]);
|
|
await phone.sync.sync('timmy');
|
|
const desktop=device();
|
|
await desktop.sync.sync('timmy');
|
|
desktop.edit('desktop edit',2);
|
|
await desktop.sync.sync('timmy');
|
|
await phone.sync.sync('timmy');
|
|
phone.edit('phone edit',3);
|
|
desktop.edit('desktop second edit',4);
|
|
await desktop.sync.sync('timmy');
|
|
await phone.sync.sync('timmy');
|
|
process.stdout.write(JSON.stringify({{remote,phone:phone.drafts(),states:phone.states}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["remote"]["revision"] == 4
|
|
assert [(item["id"], item["body"]) for item in output["remote"]["drafts"]] == [
|
|
("field", "desktop second edit"),
|
|
("field-conflict-phone", "phone edit"),
|
|
]
|
|
assert output["remote"]["drafts"][1]["title"] == "Field failure (conflict copy)"
|
|
assert output["phone"] == output["remote"]["drafts"]
|
|
assert output["states"][-1] == {
|
|
"status": "conflict-preserved",
|
|
"message": "Draft changed on two devices · both versions were saved.",
|
|
}
|