import json import subprocess from pathlib import Path import httpx import pytest from src import main from src.views import dashboard SYNC = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js" def run_node(script: str) -> dict: completed = subprocess.run( ["node", "-e", script], capture_output=True, check=True, text=True ) return json.loads(completed.stdout) def test_closed_app_sync_delivers_matching_issue_once_with_original_idempotency_key(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); const item = {{ id:'capture-1', operationId:'capture-1', ownerLogin:'timmy', status:'queued', repository:'stackchain/api', title:'Offline report', body:'Full context', labelIds:[3], milestoneId:4, dueDate:'2026-08-09', queuedAt:100, }}; const state = {{item, completed:[], released:[], failed:[], calls:[]}}; const store = {{ claimNext: async owner => state.item && state.item.ownerLogin === owner ? {{...state.item}} : null, complete: async id => {{ state.completed.push(id); state.item = null; }}, release: async id => state.released.push(id), fail: async (id, message) => state.failed.push({{id,message}}), }}; const fetchJson = async (url, options = {{}}) => {{ state.calls.push({{url,options}}); if (url === 'api/v1/background-identity') return {{login:'timmy'}}; return {{repository:'stackchain/api',number:251,title:'Offline report'}}; }}; (async () => {{ const sync = createBackgroundIssueSync({{store,fetchJson}}); const result = await sync.flush(); process.stdout.write(JSON.stringify({{state,result}})); }})(); """ output = run_node(script) assert output["result"]["confirmed"][0]["number"] == 251 assert output["state"]["completed"] == ["capture-1"] assert output["state"]["released"] == [] assert output["state"]["failed"] == [] assert output["state"]["calls"][0]["url"] == "api/v1/background-identity" mutation = output["state"]["calls"][1] assert mutation["url"] == "api/v1/repos/stackchain/api/issues" assert mutation["options"]["headers"]["Idempotency-Key"] == "capture-1" assert json.loads(mutation["options"]["body"]) == { "title": "Offline report", "body": "Full context", "label_ids": [3], "milestone_id": 4, "due_date": "2026-08-09T23:59:59Z", } def test_closed_app_sync_leaves_another_accounts_issue_queued(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); const state = {{owners:[],mutations:0}}; const store = {{ claimNext: async owner => {{ state.owners.push(owner); return null; }}, countBlocked: async owner => owner === 'alexander' ? 1 : 0, complete: async () => {{}}, }}; const fetchJson = async url => {{ if (url === 'api/v1/background-identity') return {{login:'alexander'}}; state.mutations += 1; }}; (async () => {{ const result = await createBackgroundIssueSync({{store,fetchJson}}).flush(); process.stdout.write(JSON.stringify({{state,result}})); }})(); """ output = run_node(script) assert output["state"] == {"owners": ["alexander"], "mutations": 0} assert output["result"]["confirmed"] == [] assert output["result"]["blocked"] == 1 def test_transient_delivery_failure_releases_claim_and_requests_another_sync(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); let item = {{id:'capture-2',operationId:'capture-2',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}}; const state = {{released:[],completed:[]}}; const store = {{ claimNext: async () => item ? (item = null, {{id:'capture-2',operationId:'capture-2',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}}) : null, release: async id => state.released.push(id), complete: async id => state.completed.push(id), }}; const fetchJson = async url => {{ if (url === 'api/v1/background-identity') return {{login:'timmy'}}; const error = new Error('Gitea unavailable'); error.status = 503; throw error; }}; (async () => {{ let error = null; try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }} catch (caught) {{ error = caught.message; }} process.stdout.write(JSON.stringify({{state,error}})); }})(); """ output = run_node(script) assert output["state"]["released"] == ["capture-2"] assert output["state"]["completed"] == [] assert output["error"] == "Gitea unavailable" def test_permanent_delivery_failure_marks_issue_for_foreground_attention(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); let claimed = false; const state = {{failed:[],released:[]}}; const store = {{ claimNext: async () => claimed ? null : (claimed = true, {{id:'capture-3',operationId:'capture-3',ownerLogin:'timmy',repository:'o/r',title:'Invalid',body:'',labelIds:[]}}), fail: async (id, message) => state.failed.push({{id,message}}), release: async id => state.released.push(id), countBlocked: async () => 0, }}; const fetchJson = async url => {{ if (url === 'api/v1/background-identity') return {{login:'timmy'}}; const error = new Error('Title is invalid'); error.status = 422; throw error; }}; (async () => {{ const result = await createBackgroundIssueSync({{store,fetchJson}}).flush(); process.stdout.write(JSON.stringify({{state,result}})); }})(); """ output = run_node(script) assert output["state"]["failed"] == [ {"id": "capture-3", "message": "Title is invalid"} ] assert output["state"]["released"] == [] assert output["result"]["attention"] == 1 def test_issue_store_atomically_grants_one_delivery_claim(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); const records = new Map(); let tail = Promise.resolve(); const transaction = work => {{ const run = tail.then(() => work({{ getAll: async () => [...records.values()].map(value => ({{...value}})), put: async value => records.set(value.id, {{...value}}), delete: async id => records.delete(id), }})); tail = run.catch(() => {{}}); return run; }}; (async () => {{ const store = createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>1000,claimMs:5000}}); await store.reconcile([{{id:'same',operationId:'same',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Once',labelIds:[]}}]); const claims = await Promise.all([store.claimNext('timmy'),store.claimNext('timmy')]); process.stdout.write(JSON.stringify({{claims,records:[...records.values()]}})); }})(); """ output = run_node(script) assert sum(claim is not None for claim in output["claims"]) == 1 assert output["records"][0]["status"] == "sending" assert output["records"][0]["claimUntil"] == 6000 def test_foreground_and_worker_race_still_posts_one_issue(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); const records = new Map(); let tail = Promise.resolve(); const transaction = work => {{ const run = tail.then(() => work({{ getAll:async()=>[...records.values()].map(value=>({{...value}})), put:async value=>records.set(value.id,{{...value}}), delete:async id=>records.delete(id), }})); tail=run.catch(()=>{{}}); return run; }}; const item={{id:'race',operationId:'race',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Exactly once',body:'',labelIds:[]}}; let mutations=0; const fetchJson=async url=>{{ if(url==='api/v1/background-identity') return {{login:'timmy'}}; mutations+=1; return {{number:77}}; }}; (async()=>{{ const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}}); await store.reconcile([item]); const sync=createBackgroundIssueSync({{store,fetchJson}}); const [foreground,worker]=await Promise.all([sync.send(item,'timmy'),sync.flush()]); process.stdout.write(JSON.stringify({{foreground,worker,mutations,remaining:[...records.values()]}})); }})(); """ output = run_node(script) assert output["mutations"] == 1 assert len(output["remaining"]) == 1 assert output["remaining"][0]["status"] == "sent" delivered = int(bool(output["foreground"].get("issue"))) + len( output["worker"]["confirmed"] ) assert delivered == 1 def test_foreground_send_upserts_before_claim_when_mirror_is_still_pending(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); let record = null; const state={{upserts:0,mutations:0}}; const store={{ upsert:async item=>{{state.upserts+=1;record={{...item}};}}, claim:async(id,owner)=>record?.id===id&&record?.ownerLogin===owner?{{...record}}:null, complete:async()=>{{record=null;}}, release:async()=>{{}}, fail:async()=>{{}}, }}; const fetchJson=async()=>{{state.mutations+=1;return {{number:88}};}}; (async()=>{{ const sync=createBackgroundIssueSync({{store,fetchJson}}); const result=await sync.send({{id:'early',operationId:'early',ownerLogin:'timmy',repository:'o/r',title:'Fast',body:'',labelIds:[]}},'timmy'); process.stdout.write(JSON.stringify({{state,result,record}})); }})(); """ output = run_node(script) assert output["state"] == {"upserts": 1, "mutations": 1} assert output["result"]["issue"]["number"] == 88 assert output["record"] is None def test_completed_delivery_leaves_non_replayable_tombstone_for_next_page(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); const records=new Map();let tail=Promise.resolve(); const transaction=work=>{{const run=tail.then(()=>work({{ getAll:async()=>[...records.values()].map(value=>({{...value}})), put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id), }}));tail=run.catch(()=>{{}});return run;}}; (async()=>{{ const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}}); await store.reconcile([{{id:'done',ownerLogin:'timmy',status:'queued'}}]); await store.claimNext('timmy'); await store.complete('done'); const replay=await store.claimNext('timmy'); const snapshot=await store.snapshot(); process.stdout.write(JSON.stringify({{replay,snapshot}})); }})(); """ output = run_node(script) assert output["replay"] is None assert output["snapshot"] == [ {"id": "done", "ownerLogin": "timmy", "status": "sent", "claimUntil": 0} ] def test_user_edit_resets_worker_attention_item_for_retry(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); const records=new Map([['edit',{{id:'edit',ownerLogin:'timmy',status:'attention',title:'Bad',error:'Invalid'}}]]); let tail=Promise.resolve(); const transaction=work=>{{const run=tail.then(()=>work({{ getAll:async()=>[...records.values()].map(value=>({{...value}})), put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id), }}));tail=run.catch(()=>{{}});return run;}}; (async()=>{{ const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}}); await store.reconcile([{{id:'edit',ownerLogin:'timmy',status:'queued',title:'Fixed'}}]); const claimed=await store.claimNext('timmy'); process.stdout.write(JSON.stringify(claimed)); }})(); """ output = run_node(script) assert output["title"] == "Fixed" assert output["status"] == "sending" assert "error" not in output @pytest.mark.anyio async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch): calls = 0 async def user(): nonlocal calls calls += 1 return {"id": 7, "login": "timmy", "email": "private@example.com"} monkeypatch.setattr(main, "current_user", user) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/api/v1/background-identity") assert response.status_code == 200 assert response.json() == {"login": "timmy"} assert response.headers["cache-control"] == "no-store" assert calls == 1 @pytest.mark.anyio async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback(): html = await dashboard() assert '' in html assert "const backgroundIssueStore = createIssueSyncStore();" in html assert "createBackgroundIssueSync({" in html assert "backgroundSync: backgroundIssueSync" in html assert "registration.sync.register('stackchain-issue-outbox-v1')" in html assert "backgroundIssueSync.snapshot().then(records => issueOutbox.reconcileBackground(records))" in html assert "if ('indexedDB' in window)" in html