feat: sync unfiled drafts across devices (Closes #851)
This commit is contained in:
1 parent
871e35c10b
commit
3dd0ba2024
16 files changed
+759
-6
No files matched your search
@@ -112,6 +112,7 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
|
||||
== 64 * 1024
|
||||
)
|
||||
assert main.request_body_limit("GET", "/api/v1/context") is None
|
||||
assert main.request_body_limit("PUT", "/api/v1/unfiled-drafts") == 17 * 1024 * 1024
|
||||
assert (
|
||||
main.request_body_limit(
|
||||
"POST", "/api/v1/repos/stackchain/project/issues/17/attachments"
|
||||
|
||||
@@ -797,6 +797,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/unfiled-captures.js",
|
||||
"/dashboard/static/unfiled-draft-sync.js",
|
||||
"/dashboard/static/shared-image-capture.js",
|
||||
"/dashboard/static/draft-filing-session.js",
|
||||
"/dashboard/static/draft-capacity-dialog.js",
|
||||
|
||||
@@ -104,6 +104,47 @@ process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.at
|
||||
assert output["stored"] == output["notes"]
|
||||
|
||||
|
||||
def test_unfiled_captures_export_and_import_ordered_evidence_between_devices():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
function device() {{
|
||||
const values=new Map(), blobs=new Map();
|
||||
return {{
|
||||
captures:createUnfiledCaptures({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}},
|
||||
attachmentStore:{{put:async(id,v)=>blobs.set(id,v),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}},
|
||||
getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'phone-draft',now:()=>42,
|
||||
}}), blobs,
|
||||
}};
|
||||
}}
|
||||
(async()=>{{
|
||||
const phone=device(), desktop=device();
|
||||
const image=(name,note)=>({{filename:name,contentType:'image/png',note,blob:new Blob([name],{{type:'image/png'}})}});
|
||||
await phone.captures.save({{title:'Field failure',body:'Steps',attachments:[image('one.png','First'),image('two.png','Second')],blockers:[{{repository:'o/api',number:7,title:'API'}}]}});
|
||||
const exported=await phone.captures.exportOwned('timmy');
|
||||
const imported=await desktop.captures.mergeRemote(exported,'timmy');
|
||||
const resumed=await desktop.captures.resume('phone-draft','timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
exported:exported[0], imported,
|
||||
names:resumed.attachments.map(item=>item.filename),
|
||||
notes:resumed.attachments.map(item=>item.note),
|
||||
bytes:await Promise.all(resumed.attachments.map(async item=>Buffer.from(await item.blob.arrayBuffer()).toString())),
|
||||
blockers:resumed.blockers,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["exported"]["id"] == "phone-draft"
|
||||
assert output["exported"]["saved_at"] == 42
|
||||
assert [item["filename"] for item in output["exported"]["evidence"]] == ["one.png", "two.png"]
|
||||
assert output["imported"] == 1
|
||||
assert output["names"] == ["one.png", "two.png"]
|
||||
assert output["notes"] == ["First", "Second"]
|
||||
assert output["bytes"] == ["one.png", "two.png"]
|
||||
assert output["blockers"] == [{"repository": "o/api", "number": 7, "title": "API"}]
|
||||
|
||||
|
||||
def test_unfiled_capture_restores_selected_blockers():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.frontend_bundle import FEATURE_SOURCES, build_frontend
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
|
||||
|
||||
def test_cross_device_unfiled_draft_sync_is_shipped_and_wired_to_user_flow():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert '<script src="static/unfiled-draft-sync.js"></script>' in html
|
||||
assert "static/unfiled-draft-sync.js" in FEATURE_SOURCES["today-timer"]
|
||||
assert "createUnfiledDraftSync(" in dashboard
|
||||
assert "unfiledCaptures, fetchReviewJson" in dashboard
|
||||
assert "Saved on this device · sync pending" in (ROOT / "frontend" / "unfiled-draft-sync.js").read_text()
|
||||
|
||||
built = build_frontend(ROOT / "frontend")
|
||||
assert "unfiled-draft-sync.js" not in built.dashboard_html
|
||||
assert b"stackchain.unfiled-draft-sync.v1" in built.feature_bundles["today-timer"].runtime_bytes
|
||||
@@ -0,0 +1,92 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
|
||||
|
||||
|
||||
def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None):
|
||||
return {
|
||||
"id": draft_id,
|
||||
"title": title,
|
||||
"body": "Steps from the field",
|
||||
"saved_at": 1_723_600_000_000,
|
||||
"blockers": [{"repository": "stackchain/api", "number": 7, "title": "API rollout"}],
|
||||
"evidence": (evidence if evidence is not None else [
|
||||
{
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"note": "Error after tapping Pay",
|
||||
"data": "cG5nLWJ5dGVz",
|
||||
}
|
||||
]),
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_drafts_are_revisioned_ordered_and_account_scoped(tmp_path):
|
||||
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
||||
|
||||
created = store.replace(" Timmy ", 0, [draft(), draft("second", title="Second")])
|
||||
|
||||
assert created == {"revision": 1, "drafts": [draft(), draft("second", title="Second")]}
|
||||
assert store.get("timmy") == created
|
||||
assert store.get("alexander") == {"revision": 0, "drafts": []}
|
||||
with pytest.raises(UnfiledDraftConflict) as conflict:
|
||||
store.replace("timmy", 0, [draft(title="Stale overwrite")])
|
||||
assert conflict.value.snapshot == created
|
||||
|
||||
|
||||
def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path):
|
||||
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3", limit=2, max_total_bytes=12)
|
||||
|
||||
with pytest.raises(ValueError, match="limited to 2"):
|
||||
store.replace("timmy", 0, [draft("one", evidence=[]), draft("two", evidence=[]), draft("three", evidence=[])])
|
||||
with pytest.raises(ValueError, match="evidence is too large"):
|
||||
store.replace("timmy", 0, [draft(evidence=[{
|
||||
"filename": "large.png", "content_type": "image/png", "data": "eHh4eHh4eHh4eHh4eHh4eHh4eHg=",
|
||||
}])])
|
||||
with pytest.raises(ValueError, match="valid base64"):
|
||||
store.replace("timmy", 0, [draft(evidence=[{
|
||||
"filename": "bad.png", "content_type": "image/png", "data": "not base64!",
|
||||
}])])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_safe(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_DB", str(tmp_path / "drafts.sqlite3"))
|
||||
identity = {"login": "Timmy"}
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": identity["login"]}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
payload = {"revision": 0, "drafts": [draft(evidence=[])]}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||
forbidden = await client.put("/api/v1/unfiled-drafts", json=payload)
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
saved = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers)
|
||||
stale = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers)
|
||||
fetched = await client.get("/api/v1/unfiled-drafts")
|
||||
identity["login"] = "Alexander"
|
||||
isolated = await client.get("/api/v1/unfiled-drafts")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert saved.status_code == 200
|
||||
expected = draft(evidence=[])
|
||||
expected.pop("evidence")
|
||||
assert saved.json() == {"revision": 1, "drafts": [expected]}
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["detail"]["snapshot"] == saved.json()
|
||||
assert fetched.json() == saved.json()
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
assert isolated.json() == {"revision": 0, "drafts": []}
|
||||
@@ -0,0 +1,104 @@
|
||||
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]
|
||||
Reference in new issue
Block a user