feat: paginate mobile My Work queues (#169)
This commit is contained in:
1 parent
29a786cea3
commit
625e65e7e4
8 files changed
+588
-84
No files matched your search
@@ -19,4 +19,5 @@ def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
|
||||
}
|
||||
+122
-38
@@ -2,27 +2,86 @@ import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_page_preserves_total_and_reason_without_loading_other_pages():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "84"},
|
||||
json=[{
|
||||
"id": 51,
|
||||
"number": 51,
|
||||
"title": "Older review",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/pulls/51",
|
||||
}],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.work_page("review", page=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&review_requested=true&type=pulls&limit=50&page=2"
|
||||
]
|
||||
assert result["page"] == 2
|
||||
assert result["total"] == 84
|
||||
assert result["has_more"] is False
|
||||
assert result["items"][0]["work_reasons"] == ["review_requested"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
assert page == 1
|
||||
totals = {"issue": 84, "pull": 61, "review": 73}
|
||||
return {
|
||||
"items": [], "page": 1, "total": totals[stream],
|
||||
"has_more": True, "stream": stream,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
assigned_issues = await gitea_proxy.issues()
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
assert assigned_issues.pagination == {
|
||||
"issue": {"page": 1, "total": 84, "has_more": True}
|
||||
}
|
||||
assert pulls.pagination == {
|
||||
"pull": {"page": 1, "total": 61, "has_more": True},
|
||||
"review": {"page": 1, "total": 73, "has_more": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_collections_include_supported_review_request_search(monkeypatch):
|
||||
requested_paths = []
|
||||
requested_streams = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested_paths.append(path)
|
||||
return []
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
requested_streams.append((stream, page, limit))
|
||||
return {"stream": stream, "items": [], "page": page, "total": 0, "has_more": False}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
assert await gitea_proxy.issues() == []
|
||||
assert await gitea_proxy.pull_requests() == []
|
||||
assert requested_paths == [
|
||||
"repos/issues/search?state=open&assigned=true&type=issues&limit=50",
|
||||
"repos/issues/search?state=open&assigned=true&type=pulls&limit=50",
|
||||
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
|
||||
assert requested_streams == [
|
||||
("issue", 1, 50),
|
||||
("pull", 1, 50),
|
||||
("review", 1, 50),
|
||||
]
|
||||
|
||||
|
||||
@@ -41,12 +100,16 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
|
||||
"repository": {"full_name": "stackchain/mobile"},
|
||||
}
|
||||
|
||||
async def fake_fetch(path):
|
||||
if "assigned=true" in path:
|
||||
return [assigned]
|
||||
return [assigned.copy(), review_only]
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
items = [assigned] if stream == "pull" else [assigned.copy(), review_only]
|
||||
reason = "assigned_to_me" if stream == "pull" else "review_requested"
|
||||
return {
|
||||
"stream": stream,
|
||||
"items": [{**item, "work_reasons": [reason]} for item in items],
|
||||
"page": page, "total": len(items), "has_more": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
@@ -60,13 +123,13 @@ async def test_pull_request_searches_start_concurrently(monkeypatch):
|
||||
started = [asyncio.Event(), asyncio.Event()]
|
||||
release = asyncio.Event()
|
||||
|
||||
async def fake_fetch(path):
|
||||
index = 0 if "assigned=true" in path else 1
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
index = 0 if stream == "pull" else 1
|
||||
started[index].set()
|
||||
await release.wait()
|
||||
return []
|
||||
return {"stream": stream, "items": [], "page": page, "total": 0, "has_more": False}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
task = asyncio.create_task(gitea_proxy.pull_requests())
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
@@ -80,30 +143,51 @@ async def test_pull_request_searches_start_concurrently(monkeypatch):
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_requested_review_guard_uses_only_dedicated_review_search(monkeypatch):
|
||||
requested_paths = []
|
||||
requested = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested_paths.append(path)
|
||||
return [
|
||||
{
|
||||
"number": 7,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
},
|
||||
{
|
||||
"number": 8,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
},
|
||||
]
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
requested.append((stream, page))
|
||||
items = [] if page == 1 else [{
|
||||
"number": 77,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
}]
|
||||
return {
|
||||
"stream": stream, "items": items, "page": page,
|
||||
"total": 51, "has_more": page == 1,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
assert await gitea_proxy.is_requested_review("stackchain/api", 7) is True
|
||||
assert await gitea_proxy.is_requested_review("stackchain/api", 77) is True
|
||||
assert await gitea_proxy.is_requested_review("stackchain/api", 9) is False
|
||||
assert await gitea_proxy.is_requested_review("stackchain/private", 7) is False
|
||||
assert requested_paths == [
|
||||
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
|
||||
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
|
||||
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
|
||||
assert requested == [
|
||||
("review", 1), ("review", 2),
|
||||
("review", 1), ("review", 2),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assignment_guards_find_eligible_items_beyond_first_page(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
requested.append((stream, page))
|
||||
return {
|
||||
"stream": stream,
|
||||
"items": [] if page == 1 else [{
|
||||
"number": 77,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
}],
|
||||
"page": page, "total": 51, "has_more": page == 1,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
assert await gitea_proxy.is_assigned_issue("stackchain/api", 77) is True
|
||||
assert await gitea_proxy.is_assigned_pull("stackchain/api", 77) is True
|
||||
assert requested == [
|
||||
("issue", 1), ("issue", 2),
|
||||
("pull", 1), ("pull", 2),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -119,6 +119,102 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
|
||||
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1, "update": 0}
|
||||
|
||||
|
||||
def test_work_pager_is_single_flight_and_unions_pull_responsibilities():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let calls = 0;
|
||||
let release;
|
||||
const pages = [];
|
||||
const states = [];
|
||||
const statuses = [];
|
||||
const pager = buildMyWork.createWorkPager({{
|
||||
load: (stream, page) => {{
|
||||
calls += 1;
|
||||
return new Promise(resolve => {{ release = () => resolve({{
|
||||
stream, page, total: 51, has_more: false,
|
||||
items: [
|
||||
{{id:1, title:'shared', work_reasons:['review_requested']}},
|
||||
{{id:51, title:'older', work_reasons:['review_requested']}}
|
||||
],
|
||||
}}); }});
|
||||
}},
|
||||
onItems: (stream, items) => states.push({{stream, items}}),
|
||||
onPagination: pagination => pages.push(pagination),
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
pager.reset({{review:{{page:1,total:51,has_more:true}}}});
|
||||
const existing = [{{id:1,title:'shared',work_reasons:['assigned_to_me']}}];
|
||||
const first = pager.loadMore('review', existing);
|
||||
const duplicate = pager.loadMore('review', existing);
|
||||
release();
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, pages, states, statuses, results
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == 1
|
||||
assert output["states"] == [{
|
||||
"stream": "review",
|
||||
"items": [
|
||||
{"id": 1, "title": "shared", "work_reasons": ["assigned_to_me", "review_requested"]},
|
||||
{"id": 51, "title": "older", "work_reasons": ["review_requested"]},
|
||||
],
|
||||
}]
|
||||
assert output["pages"][-1]["review"] == {"page": 2, "total": 51, "has_more": False}
|
||||
assert output["statuses"] == [
|
||||
"Loading older review requests…", "51 of 51 review requests loaded."
|
||||
]
|
||||
assert output["results"] == [True, False]
|
||||
|
||||
|
||||
def test_work_pager_keeps_items_and_retries_same_page_after_failure():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const requested = [];
|
||||
const states = [];
|
||||
const statuses = [];
|
||||
const pager = buildMyWork.createWorkPager({{
|
||||
load: async (stream, page) => {{ requested.push([stream,page]); throw new Error('offline'); }},
|
||||
onItems: (stream, items) => states.push(items),
|
||||
onPagination: () => {{}},
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
pager.reset({{issue:{{page:2,total:125,has_more:true}}}});
|
||||
pager.loadMore('issue', [{{id:1}}]).then(result =>
|
||||
pager.loadMore('issue', [{{id:1}}]).then(retry =>
|
||||
process.stdout.write(JSON.stringify({{requested,states,statuses,result,retry}}))
|
||||
)
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
assert json.loads(result.stdout) == {
|
||||
"requested": [["issue", 3], ["issue", 3]],
|
||||
"states": [],
|
||||
"statuses": [
|
||||
"Loading older issues…", "Could not load older issues. Retry.",
|
||||
"Loading older issues…", "Could not load older issues. Retry.",
|
||||
],
|
||||
"result": False,
|
||||
"retry": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_my_work_exposes_truthful_work_pagination_control():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="work-page-status"' in html
|
||||
assert 'id="load-more-work"' in html
|
||||
assert '>Load older work<' in html
|
||||
assert '.load-more-work { min-height:44px;' in html
|
||||
|
||||
|
||||
def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def page_loader(stream, page):
|
||||
requested.append((stream, page))
|
||||
return {
|
||||
"stream": stream,
|
||||
"page": page,
|
||||
"total": 84,
|
||||
"has_more": False,
|
||||
"items": [{
|
||||
"id": 51, "number": 51, "title": "Older issue", "state": "open",
|
||||
"labels": [], "assignees": [{"login": "timmy"}],
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/51",
|
||||
}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_page", page_loader)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/work/issue?page=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert requested == [("issue", 2)]
|
||||
assert response.json() == {
|
||||
"stream": "issue", "page": 2, "total": 84, "has_more": False,
|
||||
"items": [{
|
||||
"id": 51, "number": 51, "title": "Older issue", "state": "open",
|
||||
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/51",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_page_endpoint_rejects_unknown_stream_before_upstream_io(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def page_loader(stream, page):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_page", page_loader)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/work/unknown?page=2")
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
Reference in new issue
Block a user