stackchain-dashboard/tests/test_issue_outbox.py
timmy a4beca2d9e
All checks were successful
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 4s
feat: deliver offline issue captures after app close (#250)
Closes #250
2026-08-08 01:44:20 +00:00

277 lines
12 KiB
Python

import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_issue_outbox_binds_items_and_only_flushes_for_the_matching_account():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const calls = [];
let confirmedLogin = 'timmy';
const outbox = createIssueOutbox({{
storage, getOwnerLogin:() => confirmedLogin, createOperationId:() => 'bound-op',
fetchJson:async url => {{ calls.push(url); return {{number:1}}; }},
}});
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bound',body:'Context'}});
confirmedLogin = 'alexander';
(async () => {{
const mismatch = await outbox.flush('alexander');
const unknown = await outbox.retry(queued.id, '');
const matched = await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{queued,mismatch,unknown,matched,calls}}));
}})();
"""
output = run_node(script)
assert output["queued"]["ownerLogin"] == "timmy"
assert output["mismatch"]["blocked"] == 1
assert output["unknown"]["blocked"] == 1
assert len(output["matched"]["confirmed"]) == 1
assert len(output["calls"]) == 1
def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let sequence = 0;
const outbox = createIssueOutbox({{storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}});
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One',labelIds:[3]}});
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two',milestoneId:4,dueDate:'2026-08-09'}});
const reloaded = createIssueOutbox({{storage}}).list();
process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.get('stackchain.issue-outbox.v1'))}}));
"""
output = run_node(script)
assert [(item["repository"], item["title"]) for item in output["items"]] == [
("stackchain/api", "First"),
("stackchain/web", "Second"),
]
assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
assert all(item["status"] == "queued" for item in output["items"])
assert output["stored"]["version"] == 2
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let sequence = 0;
const calls = [];
const outbox = createIssueOutbox({{
storage,
getOwnerLogin:()=>'timmy',
createOperationId:() => 'stable-' + (++sequence),
fetchJson:async (url, options) => {{
calls.push({{url, key:options.headers['Idempotency-Key']}});
if (calls.length === 2) {{ const error = new Error('upstream unavailable'); error.status = 503; throw error; }}
return {{repository:'stackchain/api',number:41,title:'First'}};
}},
}});
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One'}});
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two'}});
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["result"]["confirmed"][0]["number"] == 41
assert output["calls"] == [
{"url": "api/v1/repos/stackchain/api/issues", "key": "stable-1"},
{"url": "api/v1/repos/stackchain/web/issues", "key": "stable-2"},
]
assert len(output["remaining"]) == 1
assert output["remaining"][0]["title"] == "Second"
assert output["remaining"][0]["status"] == "queued"
def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let invalid = true;
const calls = [];
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'stable-edit',
fetchJson:async (_url, options) => {{
calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }}
return {{repository:'stackchain/api',number:42,title:'Fixed title'}};
}},
}});
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bad',body:'Context'}});
outbox.flush('timmy').then(async () => {{
const attention = outbox.list()[0];
outbox.update(queued.id, {{...attention,title:'Fixed title'}});
invalid = false;
const result = await outbox.retry(queued.id, 'timmy');
process.stdout.write(JSON.stringify({{attention,result,calls,remaining:outbox.list()}}));
}});
"""
output = run_node(script)
assert output["attention"]["status"] == "attention"
assert output["attention"]["error"] == "Title is invalid"
assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"]
assert output["calls"][1]["body"]["title"] == "Fixed title"
assert output["result"]["confirmed"][0]["number"] == 42
assert output["remaining"] == []
def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let calls = 0;
let release;
const gate = new Promise(resolve => {{ release = resolve; }});
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'single-flight',
fetchJson:async () => {{ calls += 1; await gate; return {{repository:'stackchain/api',number:43,title:'Once'}}; }},
}});
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Once',body:'Context'}});
const reconnect = outbox.flush('timmy');
const sendNow = outbox.retry(queued.id, 'timmy');
release();
Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.stringify({{calls,results,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["calls"] == 1
assert output["remaining"] == []
assert sum(len(result["confirmed"]) for result in output["results"]) >= 1
def test_issue_outbox_mirrors_queue_and_registers_background_sync():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const state = {{snapshots:[],syncs:0}};
const backgroundSync = {{
reconcile: async items => state.snapshots.push(items.map(item => ({{...item}}))),
requestSync: async () => {{ state.syncs += 1; }},
}};
const outbox = createIssueOutbox({{
storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'background-1',
}});
outbox.enqueue({{repository:'stackchain/api',title:'Close the app',body:'Still deliver'}});
setTimeout(() => process.stdout.write(JSON.stringify(state)), 0);
"""
output = run_node(script)
assert output["snapshots"][0][0]["operationId"] == "background-1"
assert output["snapshots"][0][0]["ownerLogin"] == "timmy"
assert output["syncs"] == 1
def test_foreground_delivery_uses_same_atomic_background_claim():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const state = {{backgroundCalls:0,directCalls:0}};
const backgroundSync = {{
reconcile: async () => {{}}, requestSync: async () => {{}},
send: async (item, login) => {{ state.backgroundCalls += 1; return {{issue:{{number:9}}, claimed:item.id + ':' + login}}; }},
}};
const outbox = createIssueOutbox({{
storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'shared-claim',
fetchJson:async () => {{ state.directCalls += 1; return {{number:10}}; }},
}});
outbox.enqueue({{repository:'o/r',title:'One owner'}});
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{state,result,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["state"] == {"backgroundCalls": 1, "directCalls": 0}
assert output["result"]["confirmed"][0]["number"] == 9
assert output["remaining"] == []
def test_foreground_background_delivery_surfaces_permanent_failure_immediately():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const error=new Error('Title is invalid');error.status=422;
const backgroundSync={{reconcile:async()=>{{}},requestSync:async()=>{{}},send:async()=>({{attention:true,error}})}};
const outbox=createIssueOutbox({{storage,backgroundSync,getOwnerLogin:()=>'timmy',createOperationId:()=>'invalid'}});
outbox.enqueue({{repository:'o/r',title:'Bad'}});
outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result,items:outbox.list()}})));
"""
output = run_node(script)
assert output["items"][0]["status"] == "attention"
assert output["items"][0]["error"] == "Title is invalid"
assert output["result"]["confirmed"] == []
def test_page_reconciles_worker_success_and_attention_into_visible_outbox():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:2,items:[
{{id:'sent',operationId:'sent',ownerLogin:'timmy',status:'queued',title:'Done'}},
{{id:'bad',operationId:'bad',ownerLogin:'timmy',status:'queued',title:'Fix me'}},
]}}));
const outbox=createIssueOutbox({{storage}});
outbox.reconcileBackground([
{{id:'sent',status:'sent'}},
{{id:'bad',status:'attention',error:'Title is invalid'}},
]);
process.stdout.write(JSON.stringify(outbox.list()));
"""
output = run_node(script)
assert output == [
{
"id": "bad",
"operationId": "bad",
"ownerLogin": "timmy",
"status": "attention",
"title": "Fix me",
"error": "Title is invalid",
}
]
@pytest.mark.anyio
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
html = await dashboard()
assert '<script src="static/issue-outbox.js"></script>' in html
assert "const issueOutbox = createIssueOutbox({" in html
assert "issueOutbox.enqueue(captureDraft)" in html
assert "issueOutbox.retry(queued.id, activeFlushLogin)" in html
assert "navigator.onLine" in html
assert "activeFlushLogin = contextIdentityFresh ?" in html
assert "flushIssueOutbox();" in html
assert 'class="draft-send"' in html
assert 'class="draft-edit"' in html
assert 'Queued for sync' in html
assert 'Needs attention' in html
assert "issueOutbox.discard(item.outbox_id)" in html
assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html