feat: acknowledge unread updates from My Work (#135)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy committed 2026-08-06 20:30:35 +00:00
1 parent 678005ece9
commit 6e900e31e3
8 files changed
+248 -6

No files matched your search

+1
View File
@@ -16,4 +16,5 @@ def test_api_requests_resolve_inside_dashboard_subpath():
for path in api_paths
} == {
"https://forge.alexanderwhitestone.com/dashboard/api/v1/live",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
}
+81
View File
@@ -189,6 +189,73 @@ process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
assert pull["has_update"] is False
def test_notification_identity_survives_merge_and_acknowledgement_preserves_assigned_work():
payload = {
"user": {"login": "timmy"},
"issues": [{
"number": 7, "title": "Assigned issue", "repository": "stackchain/api",
"assignees": ["timmy"], "url": "https://forge.example/issues/7",
}],
"notifications": [
{"id": 42, "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
"subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/7#comment"},
{"id": 43, "number": 8, "title": "Mention", "repository": "stackchain/web",
"subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/8"},
],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const before = buildMyWork({json.dumps(payload)});
const afterMerged = buildMyWork.acknowledgeNotification(before, 42);
const afterStandalone = buildMyWork.acknowledgeNotification(before, 43);
process.stdout.write(JSON.stringify({{before, afterMerged, afterStandalone}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assigned = next(item for item in output["before"] if item["kind"] == "issue")
assert assigned["notification_id"] == 42
assert assigned["has_update"] is True
assert len(output["afterMerged"]) == 2
acknowledged = next(item for item in output["afterMerged"] if item["kind"] == "issue")
assert acknowledged["has_update"] is False
assert "notification_id" not in acknowledged
assert [item["notification_id"] for item in output["afterStandalone"] if item["has_update"]] == [42]
def test_notification_acknowledger_is_single_flight_and_rolls_back_on_failure():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = [{{kind:'update', notification_id:42, has_update:true}}];
let calls = 0;
let release;
const states = [];
const statuses = [];
const controller = buildMyWork.createNotificationAcknowledger({{
markRead: () => {{ calls += 1; return new Promise((resolve, reject) => {{ release = reject; }}); }},
onItems: items => states.push(items),
onStatus: status => statuses.push(status),
}});
const first = controller.acknowledge(original, 42);
const duplicate = controller.acknowledge(original, 42);
release(new Error('offline'));
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, 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"] == [[], [{"kind": "update", "notification_id": 42, "has_update": True}]]
assert output["statuses"] == ["Marking update read…", "Could not mark update read. Retry."]
assert output["results"] == [False, False]
@pytest.mark.anyio
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard()
@@ -207,6 +274,20 @@ async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels(
assert "filterMyWork(lastMyWork, selectedWorkFilter)" in html
@pytest.mark.anyio
async def test_unread_cards_offer_accessible_mobile_mark_read_without_nested_actions():
html = await dashboard()
assert '.mark-update-read' in html and 'min-height:44px' in html
assert 'data-notification-id' in html
assert 'Unread update' in html
assert 'id="my-work-action-status"' in html and 'aria-live="assertive"' in html
assert "createNotificationAcknowledger" in html
assert "method: 'PATCH'" in html
assert "api/v1/notifications/" in html
assert '<a class="my-work-card"' not in html
@pytest.mark.anyio
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
html = await dashboard()
+47
View File
@@ -0,0 +1,47 @@
import httpx
import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monkeypatch):
calls = []
class Response:
def raise_for_status(self):
return None
class Client:
async def patch(self, path, headers):
calls.append((path, headers))
return Response()
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
await gitea_proxy.mark_notification_read(42)
assert calls == [
("/api/v1/notifications/threads/42?to-status=read", {"Authorization": "token test"})
]
@pytest.mark.anyio
async def test_mark_notification_read_api_is_bounded_and_never_cacheable(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:
response = await client.patch("/api/v1/notifications/42/read")
invalid = await client.patch("/api/v1/notifications/0/read")
assert response.status_code == 200
assert response.json() == {"id": 42, "status": "read"}
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert marked == [42]