feat: make Find Work production-schema-safe (#181)
This commit is contained in:
1 parent
b4f984e331
commit
b6c7ec83e4
7 files changed
+331
-71
No files matched your search
@@ -79,3 +79,38 @@ async def test_application_shutdown_finishes_snapshot_before_closing_transport(m
|
||||
main._live_snapshot_task = None
|
||||
|
||||
assert calls == ["start", "snapshot cancelled", "stop (done)"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_application_shutdown_cancels_available_work_scan_before_transport(monkeypatch):
|
||||
calls = []
|
||||
started = asyncio.Event()
|
||||
|
||||
async def active_scan():
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
calls.append("available scan cancelled")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
|
||||
async def stop_client():
|
||||
task = main._available_issue_snapshot_task
|
||||
calls.append(f"stop ({'done' if task is not None and task.done() else 'active'})")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
|
||||
|
||||
try:
|
||||
async with main.app.router.lifespan_context(main.app):
|
||||
main._available_issue_snapshot_task = asyncio.create_task(active_scan())
|
||||
await started.wait()
|
||||
finally:
|
||||
task = main._available_issue_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
main._available_issue_snapshot_task = None
|
||||
|
||||
assert calls == ["start", "available scan cancelled", "stop (done)"]
|
||||
+140
-11
@@ -8,6 +8,20 @@ from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_available_issue_snapshot():
|
||||
main._available_issue_snapshot_task = None
|
||||
main._available_issue_snapshot_value = None
|
||||
main._available_issue_snapshot_created_at = None
|
||||
yield
|
||||
task = main._available_issue_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
main._available_issue_snapshot_task = None
|
||||
main._available_issue_snapshot_value = None
|
||||
main._available_issue_snapshot_created_at = None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_page_preserves_total_and_reason_without_loading_other_pages():
|
||||
requests = []
|
||||
@@ -50,10 +64,11 @@ async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_p
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "77"},
|
||||
headers={"X-Total-Count": "4"},
|
||||
json=[
|
||||
{"id": 1, "number": 1, "title": "Ordinary", "state": "open",
|
||||
"updated_at": "2026-08-07T12:00:00Z", "assignees": [],
|
||||
"updated_at": "2026-08-07T12:00:00Z", "assignees": None,
|
||||
"pull_request": None,
|
||||
"labels": [], "repository": {"full_name": "stackchain/api"}},
|
||||
{"id": 2, "number": 2, "title": "Claimed", "state": "open",
|
||||
"assignees": [{"login": "alex"}], "repository": {"full_name": "stackchain/api"}},
|
||||
@@ -62,6 +77,7 @@ async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_p
|
||||
"repository": {"full_name": "stackchain/api"}},
|
||||
{"id": 4, "number": 4, "title": "Critical", "state": "open",
|
||||
"updated_at": "2026-08-07T10:00:00Z", "assignees": [],
|
||||
"pull_request": None,
|
||||
"labels": [{"name": "critical"}],
|
||||
"repository": {"full_name": "stackchain/web"}},
|
||||
],
|
||||
@@ -69,16 +85,54 @@ async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_p
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.available_issue_page(page=2)
|
||||
result = await gitea_proxy.available_issue_page(page=1)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&type=issues&limit=50&page=2"
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&type=issues&limit=50&page=1"
|
||||
]
|
||||
assert [item["title"] for item in result["items"]] == ["Critical", "Ordinary"]
|
||||
assert result == {
|
||||
"items": result["items"], "page": 2, "total": 77, "has_more": False,
|
||||
"items": result["items"], "page": 1, "total": 2, "has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_page_ranks_all_upstream_pages_before_logical_pagination():
|
||||
requests = []
|
||||
|
||||
def issue(number, *, assigned=True, critical=False, updated_at="2026-08-07T12:00:00Z"):
|
||||
return {
|
||||
"id": number, "number": number, "title": f"Issue {number}", "state": "open",
|
||||
"updated_at": updated_at, "assignees": [{"login": "alex"}] if assigned else None,
|
||||
"pull_request": None,
|
||||
"labels": [{"name": "critical"}] if critical else [],
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
}
|
||||
|
||||
first_page = [issue(number) for number in range(1, 51)]
|
||||
first_page[0] = issue(1, assigned=False, updated_at="2026-08-07T13:00:00Z")
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
page = int(request.url.params["page"])
|
||||
payload = first_page if page == 1 else [
|
||||
issue(51, assigned=False, critical=True, updated_at="2026-08-07T10:00:00Z"),
|
||||
issue(52, assigned=False, updated_at="2026-08-07T11:00:00Z"),
|
||||
]
|
||||
return httpx.Response(200, headers={"X-Total-Count": "52"}, json=payload)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.available_issue_page(page=1, limit=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 2
|
||||
assert [item["number"] for item in result["items"]] == [51, 1]
|
||||
assert result == {
|
||||
"items": result["items"], "page": 1, "total": 3, "has_more": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -86,11 +140,11 @@ async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_p
|
||||
async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def available(page):
|
||||
calls.append(page)
|
||||
return {"items": [{"number": 7}], "page": page, "total": 51, "has_more": True}
|
||||
async def available():
|
||||
calls.append(True)
|
||||
return [{"number": number} for number in range(1, 52)]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_page", available)
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
@@ -98,9 +152,84 @@ async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkey
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"items": [{"number": 7}], "page": 1, "total": 51, "has_more": True,
|
||||
"items": [{"number": number} for number in range(1, 51)],
|
||||
"page": 1, "total": 51, "has_more": True,
|
||||
}
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_coalesces_cold_scan_and_reuses_it_for_pages(monkeypatch):
|
||||
calls = 0
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def available():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
started.set()
|
||||
await release.wait()
|
||||
return [{"number": number} for number in range(1, 64)]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = asyncio.create_task(client.get("/api/v1/available-issues?page=1"))
|
||||
second = asyncio.create_task(client.get("/api/v1/available-issues?page=2"))
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
release.set()
|
||||
first_response, second_response = await asyncio.gather(first, second)
|
||||
repeated_response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert calls == 1
|
||||
assert len(first_response.json()["items"]) == 50
|
||||
assert len(second_response.json()["items"]) == 13
|
||||
assert second_response.json()["total"] == 63
|
||||
assert second_response.json()["has_more"] is False
|
||||
assert repeated_response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_retains_last_snapshot_on_refresh_failure(monkeypatch):
|
||||
async def unavailable():
|
||||
raise httpx.ConnectError("offline")
|
||||
|
||||
main._available_issue_snapshot_value = [{"number": 7}]
|
||||
main._available_issue_snapshot_created_at = 0.0
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", unavailable)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [{"number": 7}], "page": 1, "total": 1,
|
||||
"has_more": False, "stale": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_confirmed_claim_is_removed_from_retained_available_snapshot(monkeypatch):
|
||||
main._available_issue_snapshot_value = [
|
||||
{"number": 7, "repository": "stackchain/api"},
|
||||
{"number": 8, "repository": "stackchain/web"},
|
||||
]
|
||||
main._available_issue_snapshot_created_at = 10**12
|
||||
|
||||
async def claim(repository, number):
|
||||
return {"repository": repository, "number": number, "assignees": ["timmy"]}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
claimed = await client.patch("/api/v1/repos/stackchain/api/issues/7/claim")
|
||||
available = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert claimed.status_code == 200
|
||||
assert available.json() == {
|
||||
"items": [{"number": 8, "repository": "stackchain/web"}],
|
||||
"page": 1, "total": 1, "has_more": False,
|
||||
}
|
||||
assert calls == [1]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -393,7 +393,7 @@ async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_
|
||||
if request.method == "GET" and request.url.path.endswith("/issues/17"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 81, "number": 17, "title": "Available", "state": "open",
|
||||
"assignees": [], "labels": [],
|
||||
"assignees": None, "pull_request": None, "labels": [],
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
})
|
||||
if request.method == "GET" and request.url.path == "/api/v1/user":
|
||||
|
||||
@@ -245,6 +245,7 @@ let calls = 0;
|
||||
let release;
|
||||
const states = [];
|
||||
const statuses = [];
|
||||
const pages = [];
|
||||
const controller = createFindWork({{
|
||||
fetchJson: (url, options) => {{
|
||||
calls += 1;
|
||||
@@ -254,7 +255,7 @@ const controller = createFindWork({{
|
||||
}}); }});
|
||||
}},
|
||||
onItems: items => states.push(items),
|
||||
onPagination: () => {{}},
|
||||
onPagination: page => pages.push(page),
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
controller.reset({{items:[
|
||||
@@ -266,7 +267,7 @@ const first = controller.claim(item);
|
||||
const duplicate = controller.claim(item);
|
||||
release();
|
||||
Promise.all([first,duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls,states,statuses,results,remaining:controller.items()
|
||||
calls,states,statuses,pages,results,remaining:controller.items()
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
@@ -278,6 +279,7 @@ Promise.all([first,duplicate]).then(results => process.stdout.write(JSON.stringi
|
||||
assert [item["number"] for item in output["remaining"]] == [8]
|
||||
assert [item["number"] for item in output["states"][-1]] == [8]
|
||||
assert output["statuses"] == ["Assigning stackchain/api#7…", "Assigned stackchain/api#7 to you."]
|
||||
assert output["pages"][-1] == {"page": 1, "total": 1, "has_more": False}
|
||||
assert output["results"][0]["assignees"] == ["timmy"]
|
||||
assert output["results"][1]["assignees"] == ["timmy"]
|
||||
|
||||
|
||||
Reference in new issue
Block a user