feat: sync mobile queue priority across devices (Closes #1464)
This commit is contained in:
1 parent
dbc305108a
commit
b18aa5ed63
12 files changed
+609
-36
No files matched your search
@@ -215,7 +215,7 @@ def test_operator_reorders_routine_queues_without_leaking_priority_between_accou
|
||||
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.mobileQueue)"
|
||||
) == ["following", "attention", "authored"]
|
||||
expect(page.locator("#mobile-queue-priority-status")).to_have_text("Following moved earlier.")
|
||||
expect(page.locator("#mobile-queue-priority-status")).to_have_text("Sync pending.")
|
||||
controls = page.locator(".mobile-queue-priority-controls button")
|
||||
assert controls.count() == 18
|
||||
assert all((controls.nth(i).bounding_box() or {}).get("height", 0) >= 44 for i in range(controls.count()))
|
||||
|
||||
@@ -89,6 +89,133 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_priority_hydrates_syncs_and_preserves_local_order_on_conflict():
|
||||
script = f"""
|
||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||
const values = new Map();
|
||||
const defaults = ['attention','today','update','agenda','following','authored','filed','later','draft'];
|
||||
const remote = defaults.slice(); remote.splice(remote.indexOf('following'), 1); remote.splice(1, 0, 'following');
|
||||
let server = {{revision:2, order:remote.slice()}};
|
||||
let conflict = false;
|
||||
const calls = [];
|
||||
const fetchJson = async (url, init={{}}) => {{
|
||||
calls.push([url, init.method || 'GET']);
|
||||
if (!init.method) return JSON.parse(JSON.stringify(server));
|
||||
const payload = JSON.parse(init.body);
|
||||
if (conflict) {{
|
||||
const error = new Error('conflict'); error.status=409;
|
||||
error.payload={{detail:{{snapshot:JSON.parse(JSON.stringify(server))}}}};
|
||||
throw error;
|
||||
}}
|
||||
server={{revision:payload.revision + 1, order:payload.order.slice()}};
|
||||
return JSON.parse(JSON.stringify(server));
|
||||
}};
|
||||
(async () => {{
|
||||
const priority=createPriority({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
getLogin:()=>'alice', fetchJson,
|
||||
}});
|
||||
await priority.load();
|
||||
const hydrated=priority.getOrder();
|
||||
priority.move('authored', -1);
|
||||
const local=priority.getOrder();
|
||||
conflict=true;
|
||||
server={{revision:3, order:defaults.slice()}};
|
||||
await priority.sync();
|
||||
const conflicted=priority.state();
|
||||
conflict=false;
|
||||
await priority.useLocal();
|
||||
process.stdout.write(JSON.stringify({{hydrated,local,conflicted,settled:priority.state(),server,calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["hydrated"][1] == "following"
|
||||
assert payload["local"] == payload["conflicted"]["order"]
|
||||
assert payload["conflicted"]["status"] == "conflict"
|
||||
assert payload["conflicted"]["remote"] == {
|
||||
"revision": 3,
|
||||
"order": ["attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"],
|
||||
}
|
||||
assert payload["settled"]["status"] == "ready"
|
||||
assert payload["server"]["revision"] == 4
|
||||
assert payload["server"]["order"] == payload["local"]
|
||||
assert payload["calls"] == [
|
||||
["api/v1/queue-priority", "GET"],
|
||||
["api/v1/queue-priority", "PUT"],
|
||||
["api/v1/queue-priority", "PUT"],
|
||||
]
|
||||
|
||||
|
||||
def test_mobile_queue_priority_serializes_rapid_edits_and_publishes_latest_order():
|
||||
script = f"""
|
||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||
const values = new Map(); const pending=[]; const payloads=[];
|
||||
const fetchJson = async (_url, init) => new Promise(resolve => {{
|
||||
const payload=JSON.parse(init.body); payloads.push(payload); pending.push(() => resolve({{revision:payload.revision+1,order:payload.order}}));
|
||||
}});
|
||||
(async () => {{
|
||||
let priority;
|
||||
priority=createPriority({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
getLogin:()=>'alice', fetchJson, onChange:()=>{{ void priority.sync(); }},
|
||||
}});
|
||||
priority.move('following', -1);
|
||||
priority.move('following', -1);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const firstPending=pending.length;
|
||||
pending.shift()();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const secondPending=pending.length;
|
||||
pending.shift()();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({{firstPending,secondPending,payloads,state:priority.state()}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["firstPending"] == 1
|
||||
assert payload["secondPending"] == 1
|
||||
assert len(payload["payloads"]) == 2
|
||||
assert payload["payloads"][0]["revision"] == 0
|
||||
assert payload["payloads"][1]["revision"] == 1
|
||||
assert payload["payloads"][1]["order"] == payload["state"]["order"]
|
||||
assert payload["state"]["revision"] == 2
|
||||
assert payload["state"]["status"] == "ready"
|
||||
|
||||
|
||||
def test_mobile_queue_priority_discards_in_flight_results_after_account_switch():
|
||||
script = f"""
|
||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||
const values = new Map(); let login='alice'; let rejectLoad;
|
||||
const fetchJson = () => new Promise((_resolve, reject) => {{ rejectLoad=reject; }});
|
||||
(async () => {{
|
||||
const priority=createPriority({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
getLogin:()=>login, fetchJson,
|
||||
}});
|
||||
const loading=priority.load();
|
||||
login='bob';
|
||||
rejectLoad(new Error('alice offline'));
|
||||
await loading;
|
||||
process.stdout.write(JSON.stringify({{state:priority.state(),keys:Array.from(values.keys())}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["state"]["status"] == "ready"
|
||||
assert payload["state"]["order"] == [
|
||||
"attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"
|
||||
]
|
||||
assert payload["keys"] == []
|
||||
|
||||
|
||||
def test_mobile_queue_priority_renders_keyboard_controls_and_updates_immediately():
|
||||
script = f"""
|
||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||
@@ -127,7 +254,7 @@ process.stdout.write(JSON.stringify({{
|
||||
"order": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
|
||||
"earlierLabel": "Move Following earlier",
|
||||
"laterLabel": "Move Following later",
|
||||
"status": "Following moved earlier.",
|
||||
"status": "Sync pending.",
|
||||
}
|
||||
|
||||
|
||||
@@ -399,6 +526,21 @@ async def test_mobile_queue_priority_is_packaged_account_scoped_and_touch_safe()
|
||||
assert ".mobile-queue-priority-controls button { min-height:44px;" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_queue_priority_wires_cross_device_hydration_and_explicit_conflict_actions():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mobile-queue-priority-conflict"' in html
|
||||
assert 'id="keep-local-mobile-queue-priority"' in html
|
||||
assert 'id="use-remote-mobile-queue-priority"' in html
|
||||
assert "fetchJson: fetchReviewJson" in html
|
||||
assert "void mobileQueuePriority.load();" in html
|
||||
assert "void mobileQueuePriority.sync();" in html
|
||||
assert "keepLocalButton: qs('#keep-local-mobile-queue-priority')" in html
|
||||
assert "useRemoteButton: qs('#use-remote-mobile-queue-priority')" in html
|
||||
assert "error.payload = payload;" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_adaptive_mobile_queue_wiring_recommends_again_when_connectivity_changes():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore
|
||||
|
||||
|
||||
def test_queue_priority_is_revisioned_encrypted_and_account_scoped(tmp_path):
|
||||
database = tmp_path / "queue-priority.sqlite3"
|
||||
store = QueuePriorityStore(database, encryption_key=b"q" * 32)
|
||||
preferred = list(DEFAULT_QUEUE_ORDER)
|
||||
preferred.remove("following")
|
||||
preferred.insert(1, "following")
|
||||
|
||||
created = store.replace(" Timmy ", 0, preferred)
|
||||
|
||||
assert created == {"revision": 1, "order": preferred}
|
||||
assert QueuePriorityStore(database, encryption_key=b"q" * 32).get("timmy") == created
|
||||
assert store.get("alexander") == {"revision": 0, "order": list(DEFAULT_QUEUE_ORDER)}
|
||||
with sqlite3.connect(database) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT queue_order FROM queue_priorities WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v1:")
|
||||
assert "following" not in payload
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_queue_priority_api_is_authenticated_csrf_protected_no_store_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_QUEUE_PRIORITY_DB", str(tmp_path / "queue-priority.sqlite3"))
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
preferred = list(DEFAULT_QUEUE_ORDER)
|
||||
preferred.remove("following")
|
||||
preferred.insert(1, "following")
|
||||
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.put(
|
||||
"/api/v1/queue-priority", json={"revision": 0, "order": preferred}
|
||||
)
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
saved = await client.put(
|
||||
"/api/v1/queue-priority", json={"revision": 0, "order": preferred}, headers=headers
|
||||
)
|
||||
stale = await client.put(
|
||||
"/api/v1/queue-priority",
|
||||
json={"revision": 0, "order": list(DEFAULT_QUEUE_ORDER)},
|
||||
headers=headers,
|
||||
)
|
||||
fetched = await client.get("/api/v1/queue-priority")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert saved.status_code == 200
|
||||
assert saved.json() == {"revision": 1, "order": preferred}
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["detail"] == {
|
||||
"message": "Queue priority changed on another device.",
|
||||
"snapshot": saved.json(),
|
||||
}
|
||||
assert fetched.json() == saved.json()
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
@@ -7,6 +7,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from src.completed_filed_review_store import CompletedFiledReviewStore
|
||||
from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore
|
||||
from src.saved_search_store import SavedSearchStore
|
||||
|
||||
|
||||
@@ -111,3 +112,29 @@ def test_rotation_command_rewraps_completed_filed_history_without_printing_it(tm
|
||||
assert CompletedFiledReviewStore(
|
||||
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||
).get("timmy") == {"receipts": [private_receipt]}
|
||||
|
||||
|
||||
def test_rotation_command_rewraps_mobile_queue_priority(tmp_path):
|
||||
state = tmp_path / "state"
|
||||
path = state / "queue-priority.sqlite3"
|
||||
preferred = list(DEFAULT_QUEUE_ORDER)
|
||||
preferred.remove("following")
|
||||
preferred.insert(1, "following")
|
||||
expected = QueuePriorityStore(path, encryption_key=b"o" * 32).replace(
|
||||
"timmy", 0, preferred
|
||||
)
|
||||
|
||||
completed = run_rotation(state)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout)["queue-priority"] == {
|
||||
"current": 0, "failed": 0, "migrated": 1, "total": 1
|
||||
}
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT queue_order FROM queue_priorities WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v2:next:")
|
||||
assert QueuePriorityStore(
|
||||
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||
).get("timmy") == expected
|
||||
Reference in new issue
Block a user