feat: sync Today plan across devices (#357)
This commit is contained in:
1 parent
3384e56cb8
commit
020106f001
13 files changed
+640
-13
No files matched your search
@@ -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-v44" in worker
|
||||
assert "stackchain-dashboard-shell-v45" 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-v44" in worker
|
||||
assert "stackchain-dashboard-shell-v45" 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-v44" in source
|
||||
assert "stackchain-dashboard-shell-v45" 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,14 +117,14 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v44" in source
|
||||
assert "stackchain-dashboard-shell-v45" 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-v44" in source
|
||||
assert "stackchain-dashboard-shell-v45" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -319,6 +319,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/offline-today.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/detail-defer.js",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from src import main
|
||||
from src.today_store import TodayPlanFull, TodayStore
|
||||
|
||||
|
||||
def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, limit=3)
|
||||
|
||||
assert store.apply("timmy", "op-1", "add", "issue:stackchain/dashboard:1:") == {
|
||||
"revision": 1,
|
||||
"ids": ["issue:stackchain/dashboard:1:"],
|
||||
}
|
||||
store.apply("timmy", "op-2", "add", "issue:stackchain/dashboard:2:")
|
||||
store.apply("timmy", "op-3", "add", "issue:stackchain/dashboard:3:")
|
||||
moved = store.apply(
|
||||
"timmy", "op-4", "move", "issue:stackchain/dashboard:3:", direction="up"
|
||||
)
|
||||
duplicate = store.apply(
|
||||
"timmy", "op-4", "move", "issue:stackchain/dashboard:3:", direction="up"
|
||||
)
|
||||
|
||||
assert moved == duplicate == {
|
||||
"revision": 4,
|
||||
"ids": [
|
||||
"issue:stackchain/dashboard:1:",
|
||||
"issue:stackchain/dashboard:3:",
|
||||
"issue:stackchain/dashboard:2:",
|
||||
],
|
||||
}
|
||||
assert TodayStore(path, limit=3).get("timmy") == moved
|
||||
assert store.get("alexander") == {"revision": 0, "ids": []}
|
||||
|
||||
|
||||
def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", limit=2)
|
||||
store.apply("timmy", "one", "add", "issue:r:1:")
|
||||
store.apply("timmy", "two", "add", "issue:r:2:")
|
||||
|
||||
with pytest.raises(TodayPlanFull):
|
||||
store.apply("timmy", "three", "add", "issue:r:3:")
|
||||
|
||||
assert store.get("timmy") == {
|
||||
"revision": 2,
|
||||
"ids": ["issue:r:1:", "issue:r:2:"],
|
||||
}
|
||||
store.apply("timmy", "remove", "remove", "issue:r:1:")
|
||||
assert store.apply("timmy", "retry-three", "add", "issue:r:3:")["ids"] == [
|
||||
"issue:r:2:",
|
||||
"issue:r:3:",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_today_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_TODAY_DB", str(tmp_path / "today.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/today",
|
||||
json={
|
||||
"operation_id": "mobile-1",
|
||||
"action": "add",
|
||||
"item_id": "issue:stackchain/dashboard:357:",
|
||||
},
|
||||
)
|
||||
changed = await client.patch(
|
||||
"/api/v1/today",
|
||||
json={
|
||||
"operation_id": "mobile-1",
|
||||
"action": "add",
|
||||
"item_id": "issue:stackchain/dashboard:357:",
|
||||
},
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
fetched = await client.get("/api/v1/today")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert changed.status_code == 200
|
||||
assert changed.json() == fetched.json() == {
|
||||
"revision": 1,
|
||||
"ids": ["issue:stackchain/dashboard:357:"],
|
||||
}
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
@@ -0,0 +1,142 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TODAY_SYNC = Path(__file__).parents[1] / "frontend" / "today-sync.js"
|
||||
|
||||
|
||||
def test_local_operations_replay_once_then_adopt_server_order():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.get(key) || null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
const requests = [];
|
||||
let remote = {{revision: 1, ids:['issue:r:9:']}};
|
||||
const sync = createTodaySync({{
|
||||
storage,
|
||||
getLogin: () => 'timmy',
|
||||
createOperationId: () => 'fixed-op',
|
||||
fetchJson: async (url, options={{}}) => {{
|
||||
requests.push({{url, body: options.body && JSON.parse(options.body)}});
|
||||
if (!options.method) return remote;
|
||||
remote = {{revision: remote.revision + 1, ids:['issue:r:9:', options.body && JSON.parse(options.body).item_id]}};
|
||||
return remote;
|
||||
}},
|
||||
onRemoteIds: ids => {{ globalThis.adopted = ids; }},
|
||||
onStatus: status => {{ globalThis.status = status; }},
|
||||
}});
|
||||
sync.enqueue('add', 'issue:r:2:');
|
||||
(async () => {{
|
||||
await sync.flush();
|
||||
await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
requests, adopted: globalThis.adopted, status: globalThis.status,
|
||||
pending: sync.pending(),
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"api/v1/today",
|
||||
"api/v1/today",
|
||||
"api/v1/today",
|
||||
]
|
||||
assert result["requests"][1]["body"] == {
|
||||
"operation_id": "fixed-op",
|
||||
"action": "add",
|
||||
"item_id": "issue:r:2:",
|
||||
"direction": None,
|
||||
}
|
||||
assert result["adopted"] == ["issue:r:9:", "issue:r:2:"]
|
||||
assert result["status"] == "saved"
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_failed_delivery_stays_pending_for_offline_replay():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map();
|
||||
const sync = createTodaySync({{
|
||||
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 () => {{ throw new Error('offline'); }},
|
||||
onRemoteIds: () => {{}}, onStatus: value => {{ globalThis.status = value; }},
|
||||
}});
|
||||
sync.enqueue('move', 'issue:r:2:', 'up');
|
||||
(async () => {{ await sync.flush(); process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}})); }})();
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
assert result == {
|
||||
"pending": [{
|
||||
"operation_id": "offline-op",
|
||||
"action": "move",
|
||||
"item_id": "issue:r:2:",
|
||||
"direction": "up",
|
||||
}],
|
||||
"status": "pending",
|
||||
}
|
||||
|
||||
|
||||
def test_existing_device_queue_is_migrated_only_once():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map();
|
||||
let sequence = 0;
|
||||
const sync = createTodaySync({{
|
||||
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 () => ({{revision:0,ids:[]}}), onRemoteIds:()=>{{}}, onStatus:()=>{{}},
|
||||
}});
|
||||
const first = sync.migrate(['issue:r:1:', 'issue:r:2:']);
|
||||
const second = sync.migrate(['issue:r:1:', 'issue:r:2:']);
|
||||
process.stdout.write(JSON.stringify({{first, second, pending:sync.pending()}}));
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
assert result == {
|
||||
"first": True,
|
||||
"second": False,
|
||||
"pending": [
|
||||
{"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
|
||||
{"operation_id": "migration-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_server_limit_conflict_drops_rejected_add_and_adopts_server_truth():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map();
|
||||
let patch = false;
|
||||
const sync = createTodaySync({{
|
||||
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin: () => 'timmy', createOperationId: () => 'sixth',
|
||||
fetchJson: async (_url, options={{}}) => {{
|
||||
if (options.method) {{ patch = true; const error = new Error('full'); error.status = 409; throw error; }}
|
||||
return {{revision:5, ids:['1','2','3','4','5']}};
|
||||
}},
|
||||
onRemoteIds:ids=>{{globalThis.ids=ids}}, onStatus:value=>{{globalThis.status=value}},
|
||||
}});
|
||||
sync.enqueue('add', '6');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{patch,pending:sync.pending(),ids:globalThis.ids,status:globalThis.status}}));}})();
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
assert result == {
|
||||
"patch": True,
|
||||
"pending": [],
|
||||
"ids": ["1", "2", "3", "4", "5"],
|
||||
"status": "full",
|
||||
}
|
||||
@@ -78,11 +78,28 @@ process.stdout.write(JSON.stringify([
|
||||
]
|
||||
|
||||
|
||||
def test_today_queue_adopts_bounded_server_order():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
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 queue = createTodayWork({{storage, getLogin: () => 'timmy', limit: 2}});
|
||||
queue.add({{kind:'issue', repository:'r', number:1}});
|
||||
const adopted = queue.replace(['issue:r:3:', 'issue:r:2:', 'issue:r:2:', '', 'issue:r:1:']);
|
||||
process.stdout.write(JSON.stringify({{adopted, ids:queue.read()}}));
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"adopted": True,
|
||||
"ids": ["issue:r:3:", "issue:r:2:"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/today-work.js"></script>' in html
|
||||
assert '<script src="static/today-sync.js"></script>' in html
|
||||
assert 'data-work-filter="today"' in html
|
||||
assert 'data-work-count="today"' in html
|
||||
assert "const todayWork = createTodayWork({" in html
|
||||
@@ -94,6 +111,12 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||
assert 'data-today-move="down"' in html
|
||||
assert 'Today is limited to 5 items' in html
|
||||
assert '.today-actions button' in html and 'min-height:44px' in html
|
||||
assert 'id="today-sync-status"' in html
|
||||
assert "todaySync.enqueue('add'" in html
|
||||
assert "todaySync.enqueue('remove'" in html
|
||||
assert "todaySync.enqueue('move'" in html
|
||||
assert "todaySync.flush();" in html
|
||||
assert "Another device filled Today · showing its saved plan." in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -101,7 +124,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") == 2
|
||||
assert html.count("getLogin: () => planningOwnerLogin") == 3
|
||||
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