feat: paginate unread updates inbox (#147)
CI / lint (pull_request) Successful in 11s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy committed 2026-08-06 23:30:32 +00:00
1 parent 953f59fb19
commit a86de5cbf4
9 files changed
+350 -31

No files matched your search

+1
View File
@@ -17,5 +17,6 @@ def test_api_requests_resolve_inside_dashboard_subpath():
} == {
"https://forge.alexanderwhitestone.com/dashboard/api/v1/live",
"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",
}
+42 -18
View File
@@ -1,15 +1,49 @@
import httpx
import pytest
from src import gitea_proxy
@pytest.mark.anyio
async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handoff(monkeypatch):
requested_paths = []
async def test_notification_page_preserves_upstream_total_without_loading_other_pages():
requests = []
async def fake_fetch(path):
requested_paths.append(path)
return [
def upstream(request):
requests.append(str(request.url))
return httpx.Response(
200,
headers={"X-Total-Count": "125"},
json=[{
"id": 42,
"unread": True,
"repository": {"full_name": "stackchain/api"},
"subject": {
"title": "Retry failed deploy",
"type": "Issue",
"html_url": "https://forge.example/stackchain/api/issues/7",
},
}],
)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.notification_page(page=2)
finally:
await gitea_proxy.stop_client()
assert requests == [
"http://127.0.0.1:3000/api/v1/notifications?status-types=unread&limit=50&page=2"
]
assert result["page"] == 2
assert result["total"] == 125
assert result["has_more"] is True
assert [item["id"] for item in result["items"]] == [42]
@pytest.mark.anyio
async def test_unread_notifications_are_normalized_for_mobile_handoff():
result = gitea_proxy._normalize_notifications(
[
{
"id": 42,
"unread": True,
@@ -31,12 +65,7 @@ async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handof
},
"malformed",
]
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
result = await gitea_proxy.notifications()
assert requested_paths == ["notifications?status-types=unread&limit=50"]
)
assert result == [
{
"id": 42,
@@ -78,11 +107,6 @@ async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handof
@pytest.mark.anyio
async def test_notification_collection_rejects_non_list_payload(monkeypatch):
async def fake_fetch(_path):
return {"message": "unexpected"}
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
async def test_notification_collection_rejects_non_list_payload():
with pytest.raises(ValueError, match="notification response was not a list"):
await gitea_proxy.notifications()
gitea_proxy._normalize_notifications({"message": "unexpected"})
+9 -1
View File
@@ -37,7 +37,12 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
return [{"type": "push"}]
async def updates():
return [{"id": 42, "title": "Mentioned you"}]
return {
"items": [{"id": 42, "title": "Mentioned you"}],
"page": 1,
"total": 125,
"has_more": True,
}
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
@@ -53,6 +58,9 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
assert result["context"]["user"]["login"] == "timmy"
assert result["events"] == [{"type": "push"}]
assert result["notifications"] == [{"id": 42, "title": "Mentioned you"}]
assert result["notification_pagination"] == {
"page": 1, "total": 125, "has_more": True
}
assert result["sections"] == {
"context": "fresh", "events": "fresh", "notifications": "fresh"
}
+96 -2
View File
@@ -305,6 +305,85 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
]
def test_notification_pager_is_single_flight_and_merges_unique_updates():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let calls = 0;
let release;
const pages = [];
const notifications = [];
const statuses = [];
const pager = buildMyWork.createNotificationPager({{
load: page => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{
items: [{{id:50, title:'duplicate'}}, {{id:51, title:'older'}}],
page, total: 75, has_more: false,
}}); }});
}},
onNotifications: items => notifications.push(items),
onPagination: page => pages.push(page),
onStatus: status => statuses.push(status),
}});
pager.reset({{page:1, total:75, has_more:true}});
const existing = [{{id:50, title:'newer'}}];
const first = pager.loadMore(existing);
const duplicate = pager.loadMore(existing);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, pages, notifications, 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["notifications"] == [[
{"id": 50, "title": "newer"},
{"id": 51, "title": "older"},
]]
assert output["pages"][-1] == {"page": 2, "total": 75, "has_more": False}
assert output["statuses"] == ["Loading older updates…", "75 of 75 unread updates loaded."]
assert output["results"] == [True, False]
def test_notification_pager_keeps_loaded_updates_and_retries_the_same_page_after_failure():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const states = [];
const statuses = [];
const requested = [];
const pager = buildMyWork.createNotificationPager({{
load: async page => {{ requested.push(page); throw new Error('offline'); }},
onNotifications: items => states.push(items),
onPagination: () => {{}},
onStatus: status => statuses.push(status),
}});
pager.reset({{page:2, total:125, has_more:true}});
pager.loadMore([{{id:1}}]).then(result =>
pager.loadMore([{{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": [3, 3],
"states": [],
"statuses": [
"Loading older updates…", "Could not load older updates. Retry.",
"Loading older updates…", "Could not load older updates. Retry.",
],
"result": False,
"retry": False,
}
@pytest.mark.anyio
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard()
@@ -347,13 +426,28 @@ async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement(
assert '.my-work-bulk { position:sticky;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
assert "'Mark all ' + ids.length + ' updates read'" in html
assert "'Confirm marking ' + ids.length + ' updates read'" in html
assert "'next ' + ids.length + ' of ' + allIds.length + ' loaded updates'" in html
assert "'Confirm marking ' + bulkLabel + ' read'" in html
assert "const ids = allIds.slice(0, 50)" in html
assert "createBulkNotificationAcknowledger" in html
assert "api/v1/notifications/read" in html
assert "body: JSON.stringify({ ids })" in html
@pytest.mark.anyio
async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
html = await dashboard()
assert 'id="notification-page-status"' in html
assert 'id="load-more-notifications"' in html
assert '.load-more-notifications' in html and 'min-height:44px' in html
assert "createNotificationPager" in html
assert "api/v1/notifications?page=" in html
assert "snapshot.notification_pagination" in html
assert "notificationPager.loadMore(lastNotifications)" in html
assert "Math.min(lastNotifications.length, 50)" in html
@pytest.mark.anyio
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
html = await dashboard()
+33
View File
@@ -0,0 +1,33 @@
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_notification_page_endpoint_loads_only_requested_page_and_is_not_cacheable(monkeypatch):
requested = []
async def page_loader(page):
requested.append(page)
return {
"items": [{"id": 51, "title": "Older update"}],
"page": page,
"total": 125,
"has_more": True,
}
monkeypatch.setattr(main.gitea_proxy, "notification_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/notifications?page=2")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"items": [{"id": 51, "title": "Older update"}],
"page": 2,
"total": 125,
"has_more": True,
}
assert requested == [2]