feat: sync Later deferrals across devices (#363)
This commit is contained in:
1 parent
4bc2881a4b
commit
454022ee89
14 files changed
+706
-19
No files matched your search
@@ -0,0 +1,94 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.later_store import LaterStore
|
||||
|
||||
|
||||
def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path)
|
||||
|
||||
deferred = store.apply(
|
||||
"Timmy",
|
||||
"op-1",
|
||||
"defer",
|
||||
"issue:stackchain/dashboard:363:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
duplicate = store.apply(
|
||||
"timmy",
|
||||
"op-1",
|
||||
"defer",
|
||||
"issue:stackchain/dashboard:363:",
|
||||
wake_at="2026-08-11T09:00:00.000Z",
|
||||
)
|
||||
|
||||
assert duplicate == deferred == {
|
||||
"revision": 1,
|
||||
"records": {
|
||||
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
|
||||
},
|
||||
}
|
||||
assert LaterStore(path).get("timmy") == deferred
|
||||
assert store.get("alexander") == {"revision": 0, "records": {}}
|
||||
|
||||
assert store.apply(
|
||||
"timmy", "op-2", "restore", "issue:stackchain/dashboard:363:"
|
||||
) == {"revision": 2, "records": {}}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(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_LATER_DB", str(tmp_path / "later.sqlite3"))
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
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.patch(
|
||||
"/api/v1/later",
|
||||
json={
|
||||
"operation_id": "mobile-1",
|
||||
"action": "defer",
|
||||
"item_id": "issue:stackchain/dashboard:363:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
},
|
||||
)
|
||||
changed = await client.patch(
|
||||
"/api/v1/later",
|
||||
json={
|
||||
"operation_id": "mobile-1",
|
||||
"action": "defer",
|
||||
"item_id": "issue:stackchain/dashboard:363:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
},
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
fetched = await client.get("/api/v1/later")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert changed.status_code == 200
|
||||
assert changed.json() == fetched.json() == {
|
||||
"revision": 1,
|
||||
"records": {
|
||||
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
|
||||
},
|
||||
}
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
@@ -0,0 +1,210 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
LATER_SYNC = Path(__file__).parents[1] / "frontend" / "later-sync.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(
|
||||
subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
)
|
||||
|
||||
|
||||
def test_offline_deferral_replays_once_and_adopts_server_records():
|
||||
script = f"""
|
||||
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
||||
const values = new Map();
|
||||
const requests = [];
|
||||
let remote = {{revision:1,records:{{'issue:r:9:':'2026-08-11T09:00:00.000Z'}}}};
|
||||
const sync = createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy', createOperationId:()=> 'offline-op',
|
||||
fetchJson:async (url,options={{}})=>{{
|
||||
requests.push({{url,body:options.body&&JSON.parse(options.body)}});
|
||||
if (!options.method) return remote;
|
||||
const operation=JSON.parse(options.body);
|
||||
remote={{revision:2,records:{{...remote.records,[operation.item_id]:operation.wake_at}}}};
|
||||
return remote;
|
||||
}},
|
||||
onRemoteRecords:records=>{{globalThis.records=records}},
|
||||
onStatus:status=>{{globalThis.status=status}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{await sync.flush();await sync.flush();process.stdout.write(JSON.stringify({{
|
||||
requests,records:globalThis.records,status:globalThis.status,pending:sync.pending()
|
||||
}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"api/v1/later",
|
||||
"api/v1/later",
|
||||
"api/v1/later",
|
||||
]
|
||||
assert result["requests"][1]["body"] == {
|
||||
"operation_id": "offline-op",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
}
|
||||
assert result["records"]["issue:r:2:"] == "2026-08-10T09:00:00.000Z"
|
||||
assert result["status"] == "saved"
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_latest_offline_intent_wins_and_failed_delivery_stays_pending():
|
||||
script = f"""
|
||||
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); let sequence=0;
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
|
||||
fetchJson:async()=>{{throw new Error('offline')}},onRemoteRecords:()=>{{}},onStatus:s=>{{globalThis.status=s}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
sync.enqueue('restore','issue:r:2:');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}}));}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"pending": [
|
||||
{
|
||||
"operation_id": "op-2",
|
||||
"action": "restore",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": None,
|
||||
}
|
||||
],
|
||||
"status": "pending",
|
||||
}
|
||||
|
||||
|
||||
def test_change_queued_during_delivery_is_drained_before_flush_settles():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const actions=[]; let sequence=0; let releaseFirst;
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
|
||||
fetchJson:async (_url,options={{}})=>{{
|
||||
if (!options.method) return {{revision:0,records:{{}}}};
|
||||
const operation=JSON.parse(options.body); actions.push(operation.action);
|
||||
if (operation.action==='defer') await new Promise(resolve=>releaseFirst=resolve);
|
||||
return {{revision:actions.length,records:operation.action==='defer'?{{[operation.item_id]:operation.wake_at}}:{{}}}};
|
||||
}},onRemoteRecords:r=>{{globalThis.records=r}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{const flushing=sync.flush();while(!releaseFirst) await Promise.resolve();
|
||||
sync.enqueue('restore','issue:r:2:');releaseFirst();await flushing;
|
||||
process.stdout.write(JSON.stringify({{actions,pending:sync.pending(),records:globalThis.records}}));}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"actions": ["defer", "restore"],
|
||||
"pending": [],
|
||||
"records": {},
|
||||
}
|
||||
|
||||
|
||||
def test_tabs_reject_an_older_snapshot_after_a_newer_revision():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const listeners=[];
|
||||
const createChannel=()=>({{addEventListener:(_n,h)=>listeners.push(h),postMessage:data=>listeners.forEach(h=>h({{data}}))}});
|
||||
let resolveOld,resolveNew; const oldHistory=[],newHistory=[];
|
||||
const oldTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveOld=r),onRemoteRecords:r=>oldHistory.push(r),onStatus:()=>{{}}}});
|
||||
const newTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveNew=r),onRemoteRecords:r=>newHistory.push(r),onStatus:()=>{{}}}});
|
||||
(async()=>{{const oldFlush=oldTab.flush();const newFlush=newTab.flush();await Promise.resolve();
|
||||
resolveNew({{revision:2,records:{{new:'2026-08-11T09:00:00.000Z'}}}});await newFlush;
|
||||
resolveOld({{revision:1,records:{{old:'2026-08-10T09:00:00.000Z'}}}});await oldFlush;
|
||||
process.stdout.write(JSON.stringify({{oldHistory,newHistory}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["oldHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
|
||||
assert result["newHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
|
||||
|
||||
|
||||
def test_existing_browser_records_migrate_once_and_lifecycle_replays():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const handlers={{}}; let sequence=0; const requests=[];
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'migration-'+(++sequence),
|
||||
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:options.method?1:0,records:{{}}}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
const records={{'issue:r:1:':'2026-08-10T09:00:00.000Z'}};
|
||||
const first=sync.migrate(records),second=sync.migrate(records);
|
||||
sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:{{hidden:false,addEventListener:()=>{{}}}}}});
|
||||
(async()=>{{await handlers.online();process.stdout.write(JSON.stringify({{first,second,requests,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"first": True,
|
||||
"second": False,
|
||||
"requests": ["GET", "PATCH"],
|
||||
"pending": [],
|
||||
}
|
||||
|
||||
|
||||
def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
|
||||
script = f"""
|
||||
const createLaterWork=require({json.dumps(str(LATER_WORK))});
|
||||
const values=new Map(); const changes=[]; const expired=[];
|
||||
let clock=new Date('2026-08-08T12:00:00Z');
|
||||
const item={{kind:'issue',repository:'stackchain/api',number:17}};
|
||||
const work=createLaterWork({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
|
||||
onChange:(action,id,wakeAt)=>changes.push([action,id,wakeAt]),
|
||||
onExpire:ids=>expired.push(ids),
|
||||
}});
|
||||
work.defer(item,new Date('2026-08-08T16:00:00Z'));
|
||||
work.restore(item);
|
||||
work.adopt({{'issue:stackchain/api:17:':'2026-08-09T09:00:00.000Z'}});
|
||||
const remote=work.partition([item]);
|
||||
clock=new Date('2026-08-09T09:00:01Z');
|
||||
const awake=work.partition([item]);
|
||||
process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:awake.active}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["changes"] == [
|
||||
["defer", "issue:stackchain/api:17:", "2026-08-08T16:00:00.000Z"],
|
||||
["restore", "issue:stackchain/api:17:", None],
|
||||
]
|
||||
assert result["remote"][0]["deferred_until"] == "2026-08-09T09:00:00.000Z"
|
||||
assert result["awake"] == [{"kind": "issue", "repository": "stackchain/api", "number": 17}]
|
||||
assert result["expired"] == [["issue:stackchain/api:17:"]]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/later-sync.js"></script>' in html
|
||||
assert 'id="later-sync-status"' in html
|
||||
assert "const laterSync = createLaterSync({" in html
|
||||
assert "onRemoteRecords: records =>" in html
|
||||
assert "onChange: (action, itemId, wakeAt) =>" in html
|
||||
assert "onExpire: ids =>" in html
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt)" in html
|
||||
assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
|
||||
assert "laterSync.migrate(laterWork.read());" in html
|
||||
assert "laterSync.flush();" in html
|
||||
assert "Later saved to account." in html
|
||||
|
||||
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v48" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v47" in worker
|
||||
assert "stackchain-dashboard-shell-v48" in worker
|
||||
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v47" in worker
|
||||
assert "stackchain-dashboard-shell-v48" in worker
|
||||
@@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v47" in source
|
||||
assert "stackchain-dashboard-shell-v48" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -117,21 +117,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v47" in source
|
||||
assert "stackchain-dashboard-shell-v48" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v47" in source
|
||||
assert "stackchain-dashboard-shell-v48" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v47" in source
|
||||
assert "stackchain-dashboard-shell-v48" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -329,6 +329,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/later-sync.js",
|
||||
"/dashboard/static/detail-defer.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
|
||||
@@ -185,7 +185,7 @@ async def test_retained_authenticated_context_keeps_local_planning_separate_from
|
||||
html = await dashboard()
|
||||
|
||||
assert "let planningOwnerLogin = '';" in html
|
||||
assert html.count("getLogin: () => planningOwnerLogin") == 3
|
||||
assert html.count("getLogin: () => planningOwnerLogin") == 4
|
||||
assert "const retainedPlanningLogin = !snapshot.context.error ?" in html
|
||||
assert "planningOwnerLogin = retainedPlanningLogin;" in html
|
||||
assert "button.disabled = !planningOwnerLogin;" in html
|
||||
|
||||
Reference in new issue
Block a user