feat: queue offline Search replies (Closes #959)
This commit is contained in:
1 parent
2e6c20d68d
commit
7fa43a9e50
9 files changed
+209
-22
No files matched your search
@@ -111,6 +111,46 @@ const photo=(filename,note)=>({{filename,contentType:'image/png',blob:new Blob([
|
||||
]
|
||||
|
||||
|
||||
def test_search_preview_reply_is_durably_bound_to_exact_kind_with_upload_checkpoints():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const mirrors = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
backgroundSync:{{reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const result=await outbox.enqueueDurably({{
|
||||
kind:'search-reply',targetKind:'pull',repository:'stackchain/web',number:42,
|
||||
body:'Ready offline.',operationId:'search-42',attachments:[{{
|
||||
filename:'proof.jpg',contentType:'image/jpeg',blob:new Blob(['proof']),note:'After repair',
|
||||
operationId:'photo-stage-1',confirmed:{{markdown:''}},
|
||||
}}],
|
||||
}});
|
||||
const metadata=outbox.list()[0]; const durable=mirrors[0][0];
|
||||
process.stdout.write(JSON.stringify({{
|
||||
item:result.item,metadata,
|
||||
durable:{{targetKind:durable.targetKind,operationId:durable.attachment.operationId,
|
||||
markdown:durable.attachment.confirmed?.markdown,hasBlob:durable.attachment.blob instanceof Blob}},
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["item"]["kind"] == "search-reply"
|
||||
assert output["item"]["targetKind"] == "pull"
|
||||
assert output["metadata"]["attachment"] == {
|
||||
"filename": "proof.jpg", "contentType": "image/jpeg", "stored": True,
|
||||
"note": "After repair", "operationId": "photo-stage-1",
|
||||
"confirmed": {"markdown": ""},
|
||||
}
|
||||
assert output["durable"] == {
|
||||
"targetKind": "pull", "operationId": "photo-stage-1",
|
||||
"markdown": "", "hasBlob": True,
|
||||
}
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_revision_checked_issue_content():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -19,6 +19,55 @@ def run_node(script: str) -> dict:
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_search_reply_reconnect_uses_preview_authorization_and_resumes_photo_checkpoints():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'search-42',operationId:'search-42',ownerLogin:'timmy',status:'queued',outboxLane:'authored',
|
||||
kind:'search-reply',targetKind:'pull',repository:'stackchain/web',number:42,body:'Ready offline.',attachments:[
|
||||
{{filename:'one.jpg',contentType:'image/jpeg',blob:new Blob(['one']),operationId:'photo-one',confirmed:{{markdown:''}}}},
|
||||
{{filename:'two.jpg',contentType:'image/jpeg',blob:new Blob(['two']),operationId:'photo-two'}},
|
||||
]}};
|
||||
const calls=[];let commentAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transform)=>{{item=transform(item);}},
|
||||
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},
|
||||
fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
const file=options.body instanceof FormData?options.body.get('file'):null;
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename:file?.name||'',
|
||||
body:file?null:JSON.parse(options.body)}});
|
||||
if(file)return{{markdown:''}};
|
||||
if(commentAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
|
||||
return{{id:91,body:'Ready offline.'}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';
|
||||
try{{await sync.flush();}}catch(error){{first=error.message;}}
|
||||
const checkpoint={{...item}};const second=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{calls,first,checkpoint,second}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == "offline"
|
||||
assert output["checkpoint"]["attachmentMarkdowns"] == [
|
||||
"", ""
|
||||
]
|
||||
uploads = [call for call in output["calls"] if call["filename"]]
|
||||
assert [(call["filename"], call["key"]) for call in uploads] == [
|
||||
("two.jpg", "photo-two")
|
||||
]
|
||||
comments = [call for call in output["calls"] if not call["filename"]]
|
||||
assert [call["url"] for call in comments] == [
|
||||
"api/v1/repos/stackchain/web/issues/42/preview/comments?kind=pull",
|
||||
"api/v1/repos/stackchain/web/issues/42/preview/comments?kind=pull",
|
||||
]
|
||||
assert [call["key"] for call in comments] == ["search-42:comment"] * 2
|
||||
assert comments[-1]["body"] == {
|
||||
"body": "Ready offline.\n\n\n\n"
|
||||
}
|
||||
assert output["second"]["confirmed"] == [{"id": 91, "body": "Ready offline."}]
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_matching_issue_once_with_original_idempotency_key():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
||||
@@ -389,6 +389,33 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_queue_and_next_advances_only_after_durable_admission():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async()=>{{
|
||||
const items=[1,2].map(number=>({{repository:'stackchain/api',number,kind:'issue'}}));
|
||||
const values=new Map();let admit;const navigated=[];const states=[];let cleared=0;
|
||||
const preview=createSearchPreview({{
|
||||
fetchJson:async item=>item,mutate:async()=>{{}},
|
||||
queueReply:(item,body,operationId)=>new Promise(resolve=>{{admit=()=>resolve({{id:operationId,queued:true}});}}),
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
createOperationId:()=> 'offline-search-1',hasAttachments:()=>false,clearAttachments:()=>{{cleared+=1;}},
|
||||
getSession:()=>({{items,more:false}}),onNavigate:item=>navigated.push(item.number),onState:state=>states.push(state),
|
||||
}});
|
||||
await preview.open(items[0]);preview.saveReplyDraft('Queue safely.');
|
||||
const pending=preview.reply({{advance:true}});await Promise.resolve();const before=[...navigated];admit();await pending;
|
||||
process.stdout.write(JSON.stringify({{before,navigated,cleared,draft:values.size,
|
||||
queued:states.some(state=>state.status==='queued')}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": [], "navigated": [2], "cleared": 1, "draft": 0, "queued": True
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
|
||||
@@ -130,6 +130,9 @@ def test_mobile_search_preview_exposes_and_mounts_photo_evidence_controls():
|
||||
assert mount
|
||||
assert "maxFiles: 5" in mount.group(1)
|
||||
assert "'/preview/attachments?kind='" in mount.group(1)
|
||||
assert "queueReply:async (item,body,operationId)" in dashboard
|
||||
assert "kind:'search-reply',targetKind:item.kind" in dashboard
|
||||
assert "authoredOutbox.enqueueDurably" in dashboard
|
||||
assert ".search-preview-reply .conversation-photo-actions" in css
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user