feat: paginate unread updates inbox (#147)
This commit is contained in:
1 parent
953f59fb19
commit
a86de5cbf4
9 files changed
+350
-31
No files matched your search
@@ -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",
|
||||
}
|
||||
@@ -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"})
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
Reference in new issue
Block a user