feat: sync Today blockers offline (Closes #533)
This commit is contained in:
1 parent
ee36b8824a
commit
fff7bccf08
13 files changed
+357
-17
No files matched your search
@@ -77,6 +77,41 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const calls = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
fetchJson: async (url, options) => {{
|
||||
calls.push({{url,method:options.method,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
return {{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
|
||||
}},
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
kind:'issue-blocker',repository:'stackchain/dashboard',number:17,operationId:'blocker-op',
|
||||
blockerRepository:'stackchain/api',blockerNumber:9,present:false,
|
||||
}});
|
||||
const restored = createAuthoredOutbox({{storage}}).list()[0];
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{queued,restored,calls,result,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["blockerRepository"] == "stackchain/api"
|
||||
assert output["queued"]["blockerNumber"] == 9
|
||||
assert output["queued"]["present"] is False
|
||||
assert output["restored"]["present"] is False
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
"method": "PATCH",
|
||||
"key": "blocker-op",
|
||||
"body": {"repository": "stackchain/api", "number": 9, "present": False},
|
||||
}]
|
||||
assert output["result"]["confirmed"][0]["dependencies"] == []
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_exposes_sending_then_preserves_transient_attempt_details():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -64,6 +64,54 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item = {{
|
||||
id:'blocker-1',operationId:'blocker-1',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',number:17,blockerRepository:'stackchain/api',blockerNumber:9,present:false,
|
||||
}};
|
||||
const state={{calls:[],completed:[]}};
|
||||
const store={{
|
||||
claimNext:async()=>item?{{...item}}:null,
|
||||
complete:async id=>{{state.completed.push(id);item=null;}},
|
||||
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
state.calls.push({{url,method:options.method,key:options.headers?.['Idempotency-Key']||'',body:options.body?JSON.parse(options.body):null}});
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
return{{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
|
||||
}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["calls"][1] == {
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
"method": "PATCH",
|
||||
"key": "blocker-1",
|
||||
"body": {"repository": "stackchain/api", "number": 9, "present": False},
|
||||
}
|
||||
assert output["state"]["completed"] == ["blocker-1"]
|
||||
assert output["result"]["confirmed"][0]["dependencies"] == []
|
||||
|
||||
|
||||
def test_closed_app_sync_keeps_unconfirmed_blocker_change_actionable():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item={{id:'blocker-2',operationId:'blocker-2',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',repository:'o/r',number:1,blockerRepository:'o/b',blockerNumber:2,present:false}};
|
||||
const state={{completed:[],failed:[]}};
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,complete:async id=>{{state.completed.push(id);item=null;}},release:async()=>{{}},fail:async(id,message)=>{{state.failed.push([id,message]);item=null;}},countBlocked:async()=>0}};
|
||||
const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:1,dependencies_available:true,dependencies:[{{repository:'o/b',number:2}}]}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["completed"] == []
|
||||
assert output["state"]["failed"] == [["blocker-2", "Blocker change was not confirmed."]]
|
||||
assert output["result"]["attention"] == 1
|
||||
|
||||
|
||||
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
||||
+64
-3
@@ -1977,6 +1977,32 @@ async def test_assigned_issue_blocker_route_returns_canonical_dependencies(monke
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_blocker_patch_converges_to_desired_state(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def mutate(repository, number, blocker_repository, blocker_number, remove=False):
|
||||
calls.append((repository, number, blocker_repository, blocker_number, remove))
|
||||
return {
|
||||
"repository": repository, "number": number, "dependencies_available": True,
|
||||
"dependencies": [] if remove else [{"repository": blocker_repository, "number": blocker_number}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
headers={"Idempotency-Key": "offline-blocker-1"},
|
||||
json={"repository": "stackchain/api", "number": 9, "present": False},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["dependencies"] == []
|
||||
assert calls == [("stackchain/dashboard", 17, "stackchain/api", 9, True)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_blocker_route_rejects_invalid_candidate(monkeypatch):
|
||||
async def mutate(*_args, **_kwargs):
|
||||
@@ -2037,14 +2063,49 @@ async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmat
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_dependency_retry_returns_canonical_state_without_duplicate_mutation():
|
||||
mutations = []
|
||||
dependency = {
|
||||
"number": 9, "state": "open", "title": "Restore API",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/9",
|
||||
}
|
||||
|
||||
async def handler(request):
|
||||
path = request.url.path
|
||||
if path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if path == "/api/v1/repos/stackchain/dashboard/issues/17":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
|
||||
})
|
||||
if path == "/api/v1/repos/stackchain/api/issues/9":
|
||||
return httpx.Response(200, json={"number": 9, "state": "open"})
|
||||
if request.method == "GET" and path.endswith("/dependencies"):
|
||||
return httpx.Response(200, json=[dependency])
|
||||
if path.endswith("/dependencies"):
|
||||
mutations.append(request.method)
|
||||
return httpx.Response(201, json={})
|
||||
raise AssertionError((request.method, path))
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.mutate_assigned_issue_dependency(
|
||||
"stackchain/dashboard", 17, "stackchain/api", 9
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert mutations == []
|
||||
assert result["dependencies"][0]["number"] == 9
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("candidate,blocker_repository,blocker_number,existing", [
|
||||
({"number": 17, "state": "open"}, "stackchain/dashboard", 17, []),
|
||||
({"number": 9, "state": "closed"}, "stackchain/api", 9, []),
|
||||
({"number": 9, "state": "open", "pull_request": {}}, "stackchain/api", 9, []),
|
||||
({"number": 9, "state": "open"}, "stackchain/api", 9, [
|
||||
{"number": 9, "state": "open", "repository": {"full_name": "stackchain/api"}},
|
||||
]),
|
||||
])
|
||||
async def test_gitea_dependency_rejects_invalid_candidates(
|
||||
candidate, blocker_repository, blocker_number, existing
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "offline-issue-blocker.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_offline_blocker_reports_queued_only_after_durable_admission():
|
||||
script = f"""
|
||||
const createOfflineIssueBlocker=require({json.dumps(str(MODULE))});
|
||||
const events=[];
|
||||
const queue=createOfflineIssueBlocker({{
|
||||
createOperationId:()=> 'blocker-op',
|
||||
enqueueDurably:async message=>{{events.push(['admit',message]);return{{item:message,durability:'background'}};}},
|
||||
onQueued:(item,blocker,present,admission)=>events.push(['queued',item.number,blocker.number,present,admission.durability]),
|
||||
}});
|
||||
(async()=>{{
|
||||
const result=await queue(
|
||||
{{repository:'stackchain/dashboard',number:17}},
|
||||
{{repository:'stackchain/api',number:9,title:'Restore API'}},
|
||||
false
|
||||
);
|
||||
process.stdout.write(JSON.stringify({{events,result}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["events"] == [
|
||||
["admit", {
|
||||
"kind": "issue-blocker", "repository": "stackchain/dashboard", "number": 17,
|
||||
"blockerRepository": "stackchain/api", "blockerNumber": 9,
|
||||
"present": False, "operationId": "blocker-op",
|
||||
}],
|
||||
["queued", 17, 9, False, "background"],
|
||||
]
|
||||
assert output["result"]["admission"]["durability"] == "background"
|
||||
|
||||
|
||||
def test_offline_blocker_does_not_report_queued_when_durable_admission_fails():
|
||||
script = f"""
|
||||
const createOfflineIssueBlocker=require({json.dumps(str(MODULE))});
|
||||
let queued=false;
|
||||
const queue=createOfflineIssueBlocker({{
|
||||
enqueueDurably:async()=>{{throw new Error('IndexedDB unavailable');}},
|
||||
onQueued:()=>queued=true,
|
||||
}});
|
||||
queue({{repository:'o/r',number:1}},{{repository:'o/b',number:2}},true)
|
||||
.then(()=>process.stdout.write(JSON.stringify({{queued}})))
|
||||
.catch(error=>process.stdout.write(JSON.stringify({{queued,error:error.message}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"queued": False, "error": "IndexedDB unavailable"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_issue_exposes_offline_blocker_queue_in_mobile_sheet():
|
||||
html = await dashboard()
|
||||
dashboard_js = (MODULE.parent / "dashboard.js").read_text()
|
||||
service_worker = (MODULE.parent / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/offline-issue-blocker.js"></script>' in html
|
||||
assert "BASE + 'static/offline-issue-blocker.js'" in service_worker
|
||||
assert "const queueOfflineIssueBlocker = createOfflineIssueBlocker({" in dashboard_js
|
||||
assert "qs('#manage-issue-blockers').hidden = !assignedIssue;" in dashboard_js
|
||||
assert "await queueOfflineIssueBlocker(item, blocker, !remove);" in dashboard_js
|
||||
assert "Blocker removal queued. Today remains blocked until Stackchain confirms delivery." in dashboard_js
|
||||
assert "const candidates = selectedIssueOffline ? workSession.items()" in dashboard_js
|
||||
@@ -429,6 +429,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/offline-issue-close.js",
|
||||
"/dashboard/static/offline-issue-blocker.js",
|
||||
"/dashboard/static/notification-read-outbox.js",
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/offline-today.js",
|
||||
|
||||
Reference in new issue
Block a user