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]