import json import subprocess from pathlib import Path import pytest from tests.dashboard_bundle import dashboard ROOT = Path(__file__).parents[1] CONTROLLER = ROOT / "frontend" / "update-reply-read-next.js" SYNC = ROOT / "frontend" / "background-issue-sync.js" DRAFTS = ROOT / "frontend" / "drafts.js" def run_node(script): return json.loads(subprocess.run( ["node", "-e", script], check=True, capture_output=True, text=True ).stdout) @pytest.mark.anyio async def test_every_unread_update_offers_one_reply_read_next_mobile_action(): html = await dashboard() assert 'id="send-update-reply-read-next"' in html assert '>Reply, mark read & next' in html assert "workSession.checkpointed(selectedUpdate)" not in html assert '' in html assert ".update-reply-actions button { min-height:44px; width:100%; }" in html def test_reply_read_next_orders_online_delivery_and_is_single_flight(): script = f""" const createController = require({json.dumps(str(CONTROLLER))}); const calls=[]; let releaseReply; const controller=createController({{ post:(item,body,id)=>{{calls.push(['reply',item.notification_id,body,id]);return new Promise(resolve=>releaseReply=resolve);}}, markRead:id=>{{calls.push(['read',id]);return Promise.resolve({{ok:true}});}}, queue:()=>{{throw new Error('must not queue');}}, canQueue:()=>false, accept:item=>calls.push(['accept',item.notification_id]), next:item=>{{calls.push(['next',item.notification_id]);return {{next:true}};}}, }}); const item={{notification_id:91}}; const first=controller.submit(item,'Done','op-91'); const second=controller.submit(item,'Done','op-91'); releaseReply({{id:7}}); Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{same:first===second,calls,results}}))); """ output = run_node(script) assert output == { "same": True, "calls": [ ["reply", 91, "Done", "op-91"], ["read", 91], ["accept", 91], ["next", 91], ], "results": [ {"accepted": True, "delivery": "posted", "next": {"next": True}}, {"accepted": True, "delivery": "posted", "next": {"next": True}}, ], } def test_reply_read_next_waits_until_the_next_update_is_open(): script = f""" const createController=require({json.dumps(str(CONTROLLER))}); let openNext; const controller=createController({{ post:async()=>({{id:1}}),markRead:async()=>({{ok:true}}),queue:async()=>null,canQueue:()=>false, next:()=>new Promise(resolve=>openNext=()=>resolve({{notification_id:2}})), }}); let settled=false; const result=controller.submit({{notification_id:1}},'Done','op-1').then(value=>{{settled=true;return value;}}); setTimeout(()=>{{ const before=settled; openNext(); result.then(value=>process.stdout.write(JSON.stringify({{before,value}}))); }},0); """ output = run_node(script) assert output == { "before": False, "value": { "accepted": True, "delivery": "posted", "next": {"notification_id": 2}, }, } def test_reply_read_next_durably_checkpoints_partial_delivery_without_reposting(): script = f""" const createController = require({json.dumps(str(CONTROLLER))}); const calls=[]; const offline=Object.assign(new Error('offline'),{{status:503}}); const controller=createController({{ post:async()=>{{calls.push('reply');return{{id:8}};}}, markRead:async()=>{{calls.push('read');throw offline;}}, canQueue:error=>error.status===503, queue:async message=>{{calls.push(['queue',message]);return{{item:{{id:'saved'}},background:true}};}}, accept:item=>calls.push(['accept',item.notification_id]), next:item=>{{calls.push(['next',item.notification_id]);return true;}}, }}); controller.submit({{notification_id:22}},'Ship it','stable-op').then(result=> process.stdout.write(JSON.stringify({{calls,result}}))); """ output = run_node(script) assert output["calls"] == [ "reply", "read", ["queue", { "kind": "update-reply-read", "notificationId": 22, "body": "Ship it", "operationId": "stable-op", "replyConfirmed": True, }], ["accept", 22], ["next", 22], ] assert output["result"] == {"accepted": True, "delivery": "queued", "next": True} def test_background_retry_resumes_at_read_after_reply_checkpoint(): item = { "id": "op-31", "operationId": "op-31", "ownerLogin": "timmy", "status": "queued", "kind": "update-reply-read", "notificationId": 31, "body": "Acknowledged", "replyConfirmed": True, } script = f""" const createSync=require({json.dumps(str(SYNC))}); let queued={json.dumps(item)};const calls=[]; const store={{ claimNext:async owner=>queued?(queued=null,{{...{json.dumps(item)}}}):null, complete:async()=>calls.push('complete'),release:async()=>calls.push('release'), fail:async()=>calls.push('fail'),countBlocked:async()=>0, checkpoint:async()=>{{throw new Error('must not checkpoint again');}}, }}; const fetchJson=async(url,options={{}})=>{{calls.push([url,options.method]);return url==='api/v1/background-identity'?{{login:'timmy'}}:{{ok:true}};}}; createSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}}))); """ output = run_node(script) assert output["calls"] == [ ["api/v1/background-identity", None], ["api/v1/notifications/31/read", "PATCH"], "complete", ] assert output["result"]["confirmed"] == [{"ok": True}] def test_partial_reply_read_delivery_is_recoverable_from_update_drafts(): record = {"version": 2, "items": [{ "id": "op-44", "operationId": "op-44", "ownerLogin": "timmy", "status": "attention", "kind": "update-reply-read", "notificationId": 44, "body": "Handled", "replyConfirmed": True, "error": "Read failed", }]} script = f""" const createDraftInbox=require({json.dumps(str(DRAFTS))}); const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({json.dumps(record)})]]); const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i],getItem:k=>values.get(k)||null, setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0]; process.stdout.write(JSON.stringify(item)); """ output = run_node(script) assert output["outbox_kind"] == "update-reply-read" assert output["title"] == "Update #44" assert output["route"] == {"kind": "update", "notification_id": 44} assert output["status"] == "attention"