feat: make mobile issue capture idempotent (#177)
This commit is contained in:
1 parent
9293dde1af
commit
6f54d5764b
5 files changed
+316
-19
No files matched your search
@@ -6,6 +6,13 @@ import pytest
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_issue_creation_operations():
|
||||
main._issue_creation_operations.clear()
|
||||
yield
|
||||
main._issue_creation_operations.clear()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirmed_issue(monkeypatch):
|
||||
calls = []
|
||||
@@ -60,6 +67,166 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_replays_one_upstream_result_for_concurrent_idempotent_requests(monkeypatch):
|
||||
calls = 0
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def available_repos():
|
||||
return [{"full_name": "stackchain/api"}]
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
started.set()
|
||||
await release.wait()
|
||||
return {
|
||||
"id": 81, "number": 17, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-concurrent"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = asyncio.create_task(client.post(
|
||||
"/api/v1/repos/stackchain/api/issues", json={"title": "Capture work"}, headers=headers
|
||||
))
|
||||
await started.wait()
|
||||
second = asyncio.create_task(client.post(
|
||||
"/api/v1/repos/stackchain/api/issues", json={"title": "Capture work"}, headers=headers
|
||||
))
|
||||
await asyncio.sleep(0)
|
||||
release.set()
|
||||
responses = await asyncio.gather(first, second)
|
||||
|
||||
assert [response.status_code for response in responses] == [201, 201]
|
||||
assert [response.json()["number"] for response in responses] == [17, 17]
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_key(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def available_repos():
|
||||
return [{"full_name": "stackchain/api"}]
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
calls.append(title)
|
||||
return {
|
||||
"id": 81, "number": 17, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-payload-conflict"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues", json={"title": "First title"}, headers=headers
|
||||
)
|
||||
conflict = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues", json={"title": "Changed title"}, headers=headers
|
||||
)
|
||||
|
||||
assert created.status_code == 201
|
||||
assert conflict.status_code == 409
|
||||
assert conflict.json()["detail"] == "Idempotency key already used"
|
||||
assert calls == ["First title"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_retry_recovers_result_after_the_first_request_times_out(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def available_repos():
|
||||
return [{"full_name": "stackchain/api"}]
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
await asyncio.sleep(0.03)
|
||||
return {
|
||||
"id": 81, "number": 17, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-timeout-recovery"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
timed_out = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues", json={"title": "Slow capture"}, headers=headers
|
||||
)
|
||||
await asyncio.sleep(0.03)
|
||||
recovered = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues", json={"title": "Slow capture"}, headers=headers
|
||||
)
|
||||
|
||||
assert timed_out.status_code == 503
|
||||
assert recovered.status_code == 201
|
||||
assert recovered.json()["number"] == 17
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_limit(monkeypatch):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def available_repos():
|
||||
return [{"full_name": "stackchain/api"}]
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
return {
|
||||
"id": 81, "number": 17, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES", 2)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
for index in range(3):
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues",
|
||||
json={"title": f"Capture {index}"},
|
||||
headers={"Idempotency-Key": f"capture-177-bounded-{index}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert len(main._issue_creation_operations) == 2
|
||||
assert "capture-177-bounded-0" not in main._issue_creation_operations
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmation():
|
||||
requests = []
|
||||
|
||||
@@ -395,6 +395,53 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||
assert output["results"][1]["number"] == 17
|
||||
|
||||
|
||||
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const calls = [];
|
||||
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context', labelIds:[3]}};
|
||||
const first = createIssueCapture({{
|
||||
storage,
|
||||
createOperationId: () => 'operation-177',
|
||||
fetchJson: (_url, options) => {{ calls.push(options.headers['Idempotency-Key']); return Promise.reject(new Error('timeout')); }},
|
||||
}});
|
||||
first.submit(draft).catch(() => {{
|
||||
const restored = createIssueCapture({{
|
||||
storage,
|
||||
createOperationId: () => 'must-not-replace-operation-177',
|
||||
fetchJson: (_url, options) => {{
|
||||
calls.push(options.headers['Idempotency-Key']);
|
||||
return Promise.resolve({{number:17, title:'Capture work'}});
|
||||
}},
|
||||
}});
|
||||
const before = restored.loadDraft();
|
||||
restored.submit(before).then(issue => process.stdout.write(JSON.stringify({{
|
||||
calls, before, after:restored.loadDraft(), number:issue.number
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == ["operation-177", "operation-177"]
|
||||
assert output["before"] == {
|
||||
"repository": "stackchain/api", "title": "Capture work", "body": "Context",
|
||||
"labelIds": [3],
|
||||
}
|
||||
assert output["after"] == {
|
||||
"repository": "", "title": "", "body": "", "labelIds": []
|
||||
}
|
||||
assert output["number"] == 17
|
||||
|
||||
|
||||
def test_issue_capture_loads_repository_labels_with_priorities_first():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
|
||||
Reference in new issue
Block a user