feat: bulk acknowledge unread updates (#141)
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 21:55:32 +00:00
1 parent cafc062b9f
commit db9e51e683
6 files changed
+246

No files matched your search

+1
View File
@@ -17,4 +17,5 @@ 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/read",
}
+66
View File
@@ -256,6 +256,55 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
assert output["results"] == [False, False]
def test_bulk_notification_acknowledger_deduplicates_and_keeps_partial_failures_retryable():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = [
{{kind:'issue', key:'repo#1', notification_id:42, has_update:true}},
{{kind:'update', key:'repo#2', notification_id:43, has_update:true}},
{{kind:'update', key:'repo#3', notification_id:44, has_update:true}},
];
let calls = 0;
let release;
const states = [];
const statuses = [];
const controller = buildMyWork.createBulkNotificationAcknowledger({{
markRead: ids => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{marked:[42,43], failed:[44]}}); }});
}},
onItems: items => states.push(items),
onStatus: status => statuses.push(status),
}});
const first = controller.acknowledge(original, [42, 43, 42, 44]);
const duplicate = controller.acknowledge(original, [42, 43, 44]);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, states, statuses, results,
retryIds: buildMyWork.notificationIds(states.at(-1)),
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert output["results"] == [
{"marked": [42, 43], "failed": [44]},
False,
]
assert output["states"] == [[
{"kind": "issue", "key": "repo#1", "has_update": False},
{"kind": "update", "key": "repo#3", "notification_id": 44, "has_update": True},
]]
assert output["retryIds"] == [44]
assert output["statuses"] == [
"Marking 3 updates read…",
"2 marked read · 1 could not be updated — retry.",
]
@pytest.mark.anyio
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard()
@@ -288,6 +337,23 @@ async def test_unread_cards_offer_accessible_mobile_mark_read_without_nested_act
assert '<a class="my-work-card"' not in html
@pytest.mark.anyio
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
html = await dashboard()
assert 'id="bulk-mark-read"' in html
assert 'id="bulk-mark-read-bar"' in html
assert 'class="my-work-bulk"' in html
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 "createBulkNotificationAcknowledger" in html
assert "api/v1/notifications/read" in html
assert "body: JSON.stringify({ ids })" in html
@pytest.mark.anyio
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
html = await dashboard()
+55
View File
@@ -49,6 +49,61 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
assert marked == [42]
@pytest.mark.anyio
async def test_bulk_mark_read_reports_partial_progress_and_retains_only_failures(monkeypatch):
calls = []
async def mark(thread_id):
calls.append(thread_id)
if thread_id == 43:
raise httpx.HTTPError("upstream unavailable")
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(
main,
"_live_snapshot_value",
{"notifications": [{"id": 42}, {"id": 43}, {"id": 44}]},
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/notifications/read", json={"ids": [42, 43, 42, 44]}
)
assert response.status_code == 200
assert response.json() == {"marked": [42, 44], "failed": [43]}
assert response.headers["cache-control"] == "no-store"
assert sorted(calls) == [42, 43, 44]
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
@pytest.mark.anyio
async def test_bulk_mark_read_rejects_empty_invalid_and_oversized_batches(monkeypatch):
marked = []
async def mark(thread_id):
marked.append(thread_id)
monkeypatch.setattr(main, "mark_notification_read", mark)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
empty = await client.patch("/api/v1/notifications/read", json={"ids": []})
invalid = await client.patch(
"/api/v1/notifications/read", json={"ids": [42, 0]}
)
oversized = await client.patch(
"/api/v1/notifications/read", json={"ids": list(range(1, 52))}
)
assert [empty.status_code, invalid.status_code, oversized.status_code] == [
422,
422,
422,
]
assert all(response.headers["cache-control"] == "no-store" for response in [empty, invalid, oversized])
assert marked == []
@pytest.mark.anyio
async def test_mark_notification_read_removes_thread_from_retained_live_snapshot(monkeypatch):
async def mark(_thread_id):