feat: sync completed Filed reviews across devices (Closes #880)
This commit is contained in:
1 parent
8e7a21f316
commit
6acc8866ed
7 files changed
+380
-9
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.completed_filed_review_store import CompletedFiledReviewStore
|
||||
|
||||
|
||||
def receipt(repository="stackchain/api", number=9, updated_at="2026-08-15T12:00:00Z"):
|
||||
return {"repository": repository, "number": number, "updated_at": updated_at}
|
||||
|
||||
|
||||
def test_receipts_merge_without_lost_updates_and_remain_account_scoped(tmp_path):
|
||||
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3")
|
||||
|
||||
first = store.merge(" Timmy ", [receipt()])
|
||||
second = store.merge("timmy", [receipt("stackchain/web", 4)])
|
||||
|
||||
assert first == {"receipts": [receipt()]}
|
||||
assert second == {"receipts": [receipt(), receipt("stackchain/web", 4)]}
|
||||
assert store.get("TIMMY") == second
|
||||
assert store.get("alexander") == {"receipts": []}
|
||||
|
||||
|
||||
def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
|
||||
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2)
|
||||
newer = receipt(updated_at="2026-08-16T12:00:00Z")
|
||||
|
||||
store.merge("timmy", [newer])
|
||||
assert store.merge("timmy", [receipt()]) == {"receipts": [newer]}
|
||||
bounded = store.merge("timmy", [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)])
|
||||
|
||||
assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("candidate", [
|
||||
{"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"},
|
||||
{"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"},
|
||||
{"repository": "stackchain/api", "number": 1, "updated_at": "yesterday"},
|
||||
])
|
||||
def test_receipts_reject_invalid_records(tmp_path, candidate):
|
||||
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3")
|
||||
with pytest.raises(ValueError):
|
||||
store.merge("timmy", [candidate])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_completed_filed_review_api_is_authenticated_csrf_protected_and_no_store(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_COMPLETED_FILED_REVIEW_DB", str(tmp_path / "completed.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.post("/api/v1/completed-filed-reviews", json={"receipts": [receipt()]})
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
saved = await client.post(
|
||||
"/api/v1/completed-filed-reviews", json={"receipts": [receipt()]}, headers=headers
|
||||
)
|
||||
fetched = await client.get("/api/v1/completed-filed-reviews")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert saved.status_code == 200
|
||||
assert saved.json() == {"receipts": [receipt()]}
|
||||
assert fetched.json() == saved.json()
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
@@ -141,6 +141,46 @@ if (typeof create !== 'function') {{
|
||||
}
|
||||
|
||||
|
||||
def test_completed_filed_review_merges_remote_receipts_and_keeps_local_work_pending():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key, value) => values.set(key, value),
|
||||
}};
|
||||
const item = (repository, number, updated_at) => ({{
|
||||
kind:'issue', key:repository + '#' + number, repository, number,
|
||||
is_filed:true, is_completed:true, updated_at,
|
||||
}});
|
||||
const local = item('stackchain/api', 9, '2026-08-15T12:00:00Z');
|
||||
const remote = item('stackchain/web', 4, '2026-08-14T12:00:00Z');
|
||||
const review = buildMyWork.createCompletedFiledReview({{storage, getLogin:() => 'timmy'}});
|
||||
review.acknowledge(local);
|
||||
const pendingBefore = review.pending();
|
||||
const changed = review.adopt({{receipts:[remote]}});
|
||||
const visible = review.visible([local, remote]).map(value => value.key);
|
||||
const pendingAfterRemote = review.pending();
|
||||
review.adopt({{receipts:[
|
||||
{{repository:local.repository, number:local.number, updated_at:local.updated_at}},
|
||||
{{repository:remote.repository, number:remote.number, updated_at:remote.updated_at}},
|
||||
]}});
|
||||
process.stdout.write(JSON.stringify({{pendingBefore, changed, visible, pendingAfterRemote, pendingFinal:review.pending()}}));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"pendingBefore": [receipt := {
|
||||
"repository": "stackchain/api", "number": 9, "updated_at": "2026-08-15T12:00:00Z"
|
||||
}],
|
||||
"changed": True,
|
||||
"visible": [],
|
||||
"pendingAfterRemote": [receipt],
|
||||
"pendingFinal": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
|
||||
markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
|
||||
@@ -155,6 +195,9 @@ async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
|
||||
assert "filed: 'filed issues'" in source
|
||||
handler = source.split("qs('#acknowledge-completed-filed').addEventListener('click'", 1)[1]
|
||||
assert "completedFiledReview.acknowledge(selectedIssue)" in handler
|
||||
assert "syncCompletedFiledReviews()" in source
|
||||
assert "api/v1/completed-filed-reviews" in source
|
||||
assert "sync pending" in source
|
||||
assert "filedFollowUpTarget(lastMyWork)" in handler
|
||||
assert "openRoutedWork" in handler
|
||||
assert ".completed-filed-actions" in css
|
||||
|
||||
Reference in new issue
Block a user