770 lines
31 KiB
Python
770 lines
31 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from src import main
|
|
from tests.dashboard_bundle 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_returns_privacy_safe_actionable_delivery_receipts():
|
|
script = f"""
|
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
|
const queued = [
|
|
{{id:'capture-1',operationId:'capture-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Secret title',body:'Secret body',labelIds:[]}},
|
|
{{id:'reply-1',operationId:'reply-1',ownerLogin:'timmy',status:'queued',kind:'pull-comment',repository:'stackchain/web',number:8,body:'Secret reply'}},
|
|
{{id:'bad-1',operationId:'bad-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Bad',body:'Secret failure',labelIds:[]}},
|
|
];
|
|
const store = {{
|
|
claimNext: async () => queued.shift() || null,
|
|
complete: async () => {{}}, release: async () => {{}}, fail: async () => {{}},
|
|
countBlocked: async () => 0,
|
|
}};
|
|
let issueMutations = 0;
|
|
const fetchJson = async (url) => {{
|
|
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
|
if (url.endsWith('/issues') && issueMutations++ === 0) return {{number:44,repository:'stackchain/api',title:'Secret title'}};
|
|
if (url.includes('/pulls/8/comments')) return {{id:91,body:'Secret reply'}};
|
|
const error = new Error('Sensitive validation detail'); error.status = 422; throw error;
|
|
}};
|
|
(async () => {{
|
|
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
|
process.stdout.write(JSON.stringify(result));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["login"] == "timmy"
|
|
assert output["receipts"] == [
|
|
{
|
|
"id": "capture-1",
|
|
"status": "confirmed",
|
|
"kind": "issue",
|
|
"route": "#/my-work/issue/stackchain/api/44",
|
|
},
|
|
{
|
|
"id": "reply-1",
|
|
"status": "confirmed",
|
|
"kind": "message",
|
|
"route": "#/my-work/pull/stackchain/web/8",
|
|
},
|
|
{
|
|
"id": "bad-1",
|
|
"status": "attention",
|
|
"kind": "issue",
|
|
"route": "#/my-work/drafts",
|
|
},
|
|
]
|
|
serialized = json.dumps(output["receipts"])
|
|
assert "Secret" not in serialized
|
|
assert "Sensitive" not in serialized
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("item", "expected_url"),
|
|
[
|
|
(
|
|
{"kind": "issue-comment", "repository": "stackchain/api", "number": 7},
|
|
"api/v1/repos/stackchain/api/issues/7/comments",
|
|
),
|
|
(
|
|
{"kind": "pull-comment", "repository": "stackchain/web", "number": 8},
|
|
"api/v1/repos/stackchain/web/pulls/8/comments",
|
|
),
|
|
(
|
|
{"kind": "update-reply", "notificationId": 9},
|
|
"api/v1/notifications/9/reply",
|
|
),
|
|
],
|
|
)
|
|
def test_closed_app_sync_delivers_each_authored_message_kind(item, expected_url):
|
|
authored = {
|
|
"id": "message-op",
|
|
"operationId": "message-op",
|
|
"ownerLogin": "timmy",
|
|
"status": "queued",
|
|
"body": "Ship this reply",
|
|
**item,
|
|
}
|
|
script = f"""
|
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
|
let queued = {json.dumps(authored)};
|
|
const calls = [];
|
|
const store = {{
|
|
claimNext: async owner => queued?.ownerLogin === owner ? (queued = null, {json.dumps(authored)}) : null,
|
|
complete: async () => {{}}, release: async () => {{}}, fail: async () => {{}},
|
|
countBlocked: async () => 0,
|
|
}};
|
|
const fetchJson = async (url, options = {{}}) => {{
|
|
calls.push({{url, options}});
|
|
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
|
return {{id:42}};
|
|
}};
|
|
(async () => {{
|
|
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
|
process.stdout.write(JSON.stringify({{calls,result}}));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
mutation = output["calls"][1]
|
|
assert mutation["url"] == expected_url
|
|
assert mutation["options"]["headers"]["Idempotency-Key"] == "message-op"
|
|
assert json.loads(mutation["options"]["body"]) == {"body": "Ship this reply"}
|
|
assert output["result"]["confirmed"] == [{"id": 42}]
|
|
|
|
|
|
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
|
|
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}});
|
|
await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
|
|
await store.reconcile([{{id:'message',kind:'issue-comment',ownerLogin:'timmy',status:'queued'}}], 'authored');
|
|
await store.reconcile([], 'issue');
|
|
process.stdout.write(JSON.stringify(await store.snapshot()));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output == [
|
|
{"id": "message", "kind": "issue-comment", "ownerLogin": "timmy", "status": "queued", "outboxLane": "authored"}
|
|
]
|
|
|
|
|
|
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
|
|
script = f"""
|
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
|
const state={{opens:0,closes:0}};
|
|
const indexedDB={{lastDb:null,open:()=>{{
|
|
state.opens += 1;
|
|
const request={{result:null,onsuccess:null,onerror:null,onupgradeneeded:null}};
|
|
const db={{
|
|
objectStoreNames:{{contains:()=>true}},
|
|
close:()=>{{state.closes += 1;}},
|
|
transaction:()=>{{
|
|
const tx={{oncomplete:null,onerror:null,onabort:null,error:null,objectStore:()=>({{
|
|
getAll:()=>{{const get={{onsuccess:null,onerror:null,result:[]}};queueMicrotask(()=>get.onsuccess?.());return get;}},
|
|
}})}};
|
|
setTimeout(()=>tx.oncomplete?.(),0);
|
|
return tx;
|
|
}},
|
|
}};
|
|
indexedDB.lastDb=db;request.result=db;queueMicrotask(()=>request.onsuccess?.());return request;
|
|
}}}};
|
|
(async()=>{{
|
|
const store=createBackgroundIssueSync.createIssueSyncStore({{indexedDB}});
|
|
await store.snapshot();
|
|
indexedDB.lastDb.onversionchange?.();
|
|
await store.snapshot();
|
|
process.stdout.write(JSON.stringify(state));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output == {"opens": 2, "closes": 1}
|
|
|
|
|
|
def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox():
|
|
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}});
|
|
await store.setReceiptPreference('timmy', true);
|
|
await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
|
|
const result={{
|
|
timmy:await store.getReceiptPreference('timmy'),
|
|
alexander:await store.getReceiptPreference('alexander'),
|
|
snapshot:await store.snapshot(),
|
|
blocked:await store.countBlocked('alexander'),
|
|
}};
|
|
await store.setReceiptPreference('timmy', false);
|
|
result.disabled=await store.getReceiptPreference('timmy');
|
|
process.stdout.write(JSON.stringify(result));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output == {
|
|
"timmy": True,
|
|
"alexander": False,
|
|
"snapshot": [
|
|
{
|
|
"id": "issue",
|
|
"ownerLogin": "timmy",
|
|
"status": "queued",
|
|
"outboxLane": "issue",
|
|
}
|
|
],
|
|
"blocked": 1,
|
|
"disabled": False,
|
|
}
|
|
|
|
|
|
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_session_expiry_during_delivery_releases_claim_without_attention():
|
|
script = f"""
|
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
|
const queued = [
|
|
{{id:'capture-auth',operationId:'stable-key',ownerLogin:'timmy',repository:'o/r',title:'Keep me',body:'',labelIds:[]}},
|
|
{{id:'capture-later',operationId:'later-key',ownerLogin:'timmy',repository:'o/r',title:'Do not try yet',body:'',labelIds:[]}},
|
|
];
|
|
const state = {{released:[],failed:[],mutationKeys:[]}};
|
|
const store = {{
|
|
claimNext: async () => queued.shift() || null,
|
|
release: async id => state.released.push(id),
|
|
fail: async (id,message) => state.failed.push({{id,message}}),
|
|
countBlocked: async () => 0,
|
|
}};
|
|
const fetchJson = async (url, options={{}}) => {{
|
|
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
|
state.mutationKeys.push(options.headers['Idempotency-Key']);
|
|
const error = new Error('Authentication required'); error.status = 401; throw error;
|
|
}};
|
|
(async () => {{
|
|
let error = null;
|
|
try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }}
|
|
catch (caught) {{ error = {{message:caught.message,status:caught.status}}; }}
|
|
process.stdout.write(JSON.stringify({{state,error}}));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output == {
|
|
"state": {
|
|
"released": ["capture-auth"],
|
|
"failed": [],
|
|
"mutationKeys": ["stable-key"],
|
|
},
|
|
"error": {"message": "Authentication required", "status": 401},
|
|
}
|
|
|
|
|
|
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_stalled_delivery_times_out_releases_claim_and_retries_with_same_idempotency_key():
|
|
script = f"""
|
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
|
const item = {{id:'capture-timeout',operationId:'stable-operation',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}};
|
|
const state = {{queued:true,released:[],completed:[],keys:[],attempts:0}};
|
|
const store = {{
|
|
claimNext: async () => state.queued ? (state.queued=false, {{...item}}) : null,
|
|
complete: async id => state.completed.push(id),
|
|
release: async id => {{state.released.push(id); state.queued=true;}},
|
|
fail: async () => {{}}, countBlocked: async () => 0,
|
|
}};
|
|
const fetchJson = async (url, options={{}}) => {{
|
|
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
|
state.keys.push(options.headers['Idempotency-Key']);
|
|
state.attempts += 1;
|
|
if (state.attempts === 1) return new Promise(() => {{}});
|
|
return {{repository:'o/r',number:9}};
|
|
}};
|
|
(async () => {{
|
|
const sync=createBackgroundIssueSync({{store,fetchJson,requestTimeoutMs:10}});
|
|
let firstError='';
|
|
try {{ await sync.flush(); }} catch (error) {{ firstError=error.message; }}
|
|
const second=await sync.flush();
|
|
process.stdout.write(JSON.stringify({{state,firstError,second}}));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["firstError"] == "Background request timed out."
|
|
assert output["state"]["released"] == ["capture-timeout"]
|
|
assert output["state"]["completed"] == ["capture-timeout"]
|
|
assert output["state"]["keys"] == ["stable-operation", "stable-operation"]
|
|
assert output["second"]["confirmed"][0]["number"] == 9
|
|
|
|
|
|
def test_purge_cancels_stalled_delivery_without_reclaiming_it():
|
|
script = f"""
|
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
|
const items=[
|
|
{{id:'capture-purge-1',operationId:'capture-purge-1',ownerLogin:'timmy',repository:'o/r',title:'Private',body:'Draft',labelIds:[]}},
|
|
{{id:'capture-purge-2',operationId:'capture-purge-2',ownerLogin:'timmy',repository:'o/r',title:'Private 2',body:'Draft',labelIds:[]}},
|
|
];
|
|
const state={{released:[],closed:0,mutations:0}};
|
|
let mutationStarted;
|
|
const started=new Promise(resolve => mutationStarted=resolve);
|
|
const store={{
|
|
claimBatch:async()=>items.map(item=>({{...item}})),
|
|
complete:async()=>{{}},
|
|
release:async id=>state.released.push(id),
|
|
fail:async()=>{{}}, countBlocked:async()=>0, close:async()=>{{state.closed+=1;}},
|
|
}};
|
|
const fetchJson=async url=>{{
|
|
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
|
state.mutations+=1;mutationStarted();return new Promise(()=>{{}});
|
|
}};
|
|
(async()=>{{
|
|
const sync=createBackgroundIssueSync({{store,fetchJson,maxConcurrency:1,requestTimeoutMs:1000}});
|
|
sync.flush().catch(()=>{{}});
|
|
await started;
|
|
const outcome=await Promise.race([
|
|
sync.purge().then(()=> 'purged'),
|
|
new Promise(resolve=>setTimeout(()=>resolve('blocked'),80)),
|
|
]);
|
|
process.stdout.write(JSON.stringify({{state,outcome}}));
|
|
process.exit(0);
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["outcome"] == "purged"
|
|
assert output["state"] == {
|
|
"released": ["capture-purge-1", "capture-purge-2"],
|
|
"closed": 1,
|
|
"mutations": 1,
|
|
}
|
|
|
|
|
|
def test_bounded_flush_delivers_healthy_records_after_transient_failure_without_same_run_retry():
|
|
script = f"""
|
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
|
const items = [
|
|
{{id:'issue-1',operationId:'issue-1',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'One',body:'',labelIds:[]}},
|
|
{{id:'message-1',operationId:'message-1',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:1,body:'Reply'}},
|
|
{{id:'issue-bad',operationId:'issue-bad',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Bad',body:'',labelIds:[]}},
|
|
{{id:'message-2',operationId:'message-2',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:2,body:'Reply'}},
|
|
{{id:'issue-2',operationId:'issue-2',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Two',body:'',labelIds:[]}},
|
|
];
|
|
const state = {{active:0,maxActive:0,attempted:[],completed:[],released:[],batches:0}};
|
|
const store = {{
|
|
claimBatch: async (owner, limit) => items.slice(0, limit),
|
|
complete: async id => state.completed.push(id),
|
|
release: async id => state.released.push(id),
|
|
fail: async () => {{}}, countBlocked: async () => 0,
|
|
}};
|
|
const fetchJson = async (url, options={{}}) => {{
|
|
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
|
const key = options.headers['Idempotency-Key'];
|
|
state.attempted.push(key); state.active += 1;
|
|
state.maxActive = Math.max(state.maxActive, state.active);
|
|
await new Promise(resolve => setTimeout(resolve, key === 'issue-bad' ? 5 : 20));
|
|
state.active -= 1;
|
|
if (key === 'issue-bad') {{ const error=new Error('Temporary outage'); error.status=503; throw error; }}
|
|
return {{id:key,number:7}};
|
|
}};
|
|
(async () => {{
|
|
let error;
|
|
try {{ await createBackgroundIssueSync({{
|
|
store,fetchJson,maxConcurrency:3,batchSize:10,
|
|
batch: async work => {{ state.batches += 1; return work(); }},
|
|
}}).flush(); }}
|
|
catch (caught) {{ error=caught.message; }}
|
|
process.stdout.write(JSON.stringify({{state,error}}));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["state"]["maxActive"] == 3
|
|
assert output["state"]["batches"] == 1
|
|
assert sorted(output["state"]["attempted"]) == [
|
|
"issue-1", "issue-2", "issue-bad", "message-1", "message-2"
|
|
]
|
|
assert len(output["state"]["attempted"]) == len(set(output["state"]["attempted"]))
|
|
assert sorted(output["state"]["completed"]) == [
|
|
"issue-1", "issue-2", "message-1", "message-2"
|
|
]
|
|
assert output["state"]["released"] == ["issue-bad"]
|
|
assert output["error"] == "Temporary outage"
|
|
|
|
|
|
def test_bounded_flush_stops_admission_and_releases_unstarted_claims_on_auth_loss():
|
|
script = f"""
|
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
|
const items = Array.from({{length:5}}, (_, index) => ({{
|
|
id:'item-'+index,operationId:'item-'+index,ownerLogin:'timmy',outboxLane:'issue',
|
|
repository:'o/r',title:'Item',body:'',labelIds:[],
|
|
}}));
|
|
const state = {{attempted:[],released:[],completed:[]}};
|
|
const store = {{
|
|
claimBatch: async () => items,
|
|
complete: async id => state.completed.push(id),
|
|
release: async id => state.released.push(id), fail:async()=>{{}}, countBlocked:async()=>0,
|
|
}};
|
|
const fetchJson = async (url, options={{}}) => {{
|
|
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
|
const key=options.headers['Idempotency-Key']; state.attempted.push(key);
|
|
if (key === 'item-0') {{ const error=new Error('Authentication required'); error.status=401; throw error; }}
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
return {{number:1}};
|
|
}};
|
|
(async()=>{{
|
|
let error;
|
|
try {{ await createBackgroundIssueSync({{store,fetchJson,maxConcurrency:2}}).flush(); }}
|
|
catch (caught) {{ error={{message:caught.message,status:caught.status}}; }}
|
|
process.stdout.write(JSON.stringify({{state,error}}));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["state"]["attempted"] == ["item-0", "item-1"]
|
|
assert sorted(output["state"]["released"]) == ["item-0", "item-2", "item-3", "item-4"]
|
|
assert output["state"]["completed"] == ["item-1"]
|
|
assert output["error"] == {"message": "Authentication required", "status": 401}
|
|
|
|
|
|
def test_issue_store_claims_one_finite_batch_fairly_across_outbox_lanes():
|
|
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,claimMs:5000}});
|
|
await store.reconcile([
|
|
{{id:'issue-1',ownerLogin:'timmy',status:'queued'}},
|
|
{{id:'issue-2',ownerLogin:'timmy',status:'queued'}},
|
|
{{id:'issue-3',ownerLogin:'timmy',status:'queued'}},
|
|
], 'issue');
|
|
await store.reconcile([
|
|
{{id:'message-1',ownerLogin:'timmy',status:'queued'}},
|
|
{{id:'message-2',ownerLogin:'timmy',status:'queued'}},
|
|
], 'authored');
|
|
const claimed=await store.claimBatch('timmy', 4);
|
|
process.stdout.write(JSON.stringify({{claimed,snapshot:await store.snapshot()}}));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert [item["id"] for item in output["claimed"]] == [
|
|
"issue-1", "message-1", "issue-2", "message-2"
|
|
]
|
|
assert all(item["status"] == "sending" for item in output["claimed"])
|
|
assert all(item["claimUntil"] == 5100 for item in output["claimed"])
|
|
assert next(item for item in output["snapshot"] if item["id"] == "issue-3")["status"] == "queued"
|
|
|
|
|
|
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", "outboxLane": "issue", "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 '<script src="static/background-issue-sync.js"></script>' 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 => {" in html
|
|
assert "issueOutbox.reconcileBackground(records);" in html
|
|
assert "authoredOutbox.reconcileBackground(records);" in html
|
|
assert "if ('indexedDB' in window)" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in():
|
|
html = await dashboard()
|
|
|
|
assert 'id="delivery-receipts" type="checkbox"' in html
|
|
assert "deliveryReceipts.addEventListener('change', async () => {" in html
|
|
assert "await Notification.requestPermission()" in html
|
|
assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html
|
|
assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html
|
|
assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html
|
|
assert "if (window.location.hash !== '#/my-work/drafts') return;" in html
|