feat: acknowledge unread updates with reactions (Closes #539)
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 04:10:18 +00:00
1 parent fb34b5ffda
commit d7e2813700
10 files changed
+332

No files matched your search

+1
View File
@@ -182,6 +182,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
},
"issue": {"number": 7, "assignees": [], "claimable": True},
"acknowledge_supported": True,
"conversation": {
"comments": [{
"id": 9,
+62
View File
@@ -3439,6 +3439,68 @@ reader.open(original[0], original).then(() =>
assert output["result"]["next"]["notification_id"] == 43
def test_notification_reader_acknowledges_once_and_opens_next_update():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [
{{kind:'update', notification_id:42, has_update:true}},
{{kind:'update', notification_id:43, has_update:true}},
];
let release;
const calls = [];
const events = [];
const reader = buildMyWork.createNotificationReader({{
load: async id => ({{id}}), markRead: async () => {{}},
acknowledge: id => new Promise(resolve => {{ calls.push(id); release = resolve; }}),
onOpen: item => events.push(['open', item.notification_id]), onDetail: () => {{}},
onItems: next => events.push(['items', next.map(item => item.notification_id)]),
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
}});
(async () => {{
await reader.open(items[0]);
events.length = 0;
const first = reader.acknowledgeAndNext(items);
const duplicate = reader.acknowledgeAndNext(items);
await Promise.resolve();
release({{reaction:'created', status:'read'}});
const results = await Promise.all([first, duplicate]);
process.stdout.write(JSON.stringify({{calls, events, results}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["calls"] == [42]
assert output["events"] == [
["status", "Adding reaction and marking read…"],
["items", [43]],
["open", 43],
["status", "Loading update…"],
["status", "Update ready."],
]
assert output["results"][0]["next"]["notification_id"] == 43
assert output["results"][1] is False
@pytest.mark.anyio
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
html = await dashboard()
assert (
'id="acknowledge-update-next" type="button" hidden '
'aria-label="Acknowledge and open next update">👍 Acknowledge &amp; next</button>'
) in html
assert "qs('#acknowledge-update-next').hidden = true;" in html
assert "qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;" in html
assert "async function acknowledgeNotification(notificationId)" in html
assert "'/acknowledge', { method: 'POST'" in html
assert "acknowledge: acknowledgeNotification" in html
assert "notificationReader.acknowledgeAndNext(lastMyWork)" in html
assert "qs('#acknowledge-update-next').disabled = offline;" in html
assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
+1
View File
@@ -66,6 +66,7 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol
"assignees": [],
"claimable": True,
}
assert result["acknowledge_supported"] is True
@pytest.mark.anyio
+138
View File
@@ -41,6 +41,117 @@ async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monk
]
@pytest.mark.anyio
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
calls = []
async def fake_fetch(path):
calls.append(("fetch", path))
if path == "notifications/threads/42":
return {
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Issue",
"url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
"latest_comment_url": (
"https://forge.example/api/v1/repos/stackchain/api/issues/comments/91"
),
},
}
if path == "user":
return {"login": "timmy"}
raise AssertionError(path)
class Response:
def __init__(self, payload=None):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
class Client:
async def get(self, path, headers):
calls.append(("get", path))
return Response([])
async def post(self, path, headers, json):
calls.append(("post", path, json))
return Response({"content": "+1"})
async def patch(self, path, headers):
calls.append(("patch", path))
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
result = await gitea_proxy.acknowledge_notification(42)
reaction_path = "/api/v1/repos/stackchain/api/issues/comments/91/reactions"
assert result == {"id": 42, "reaction": "created", "status": "read"}
assert calls == [
("fetch", "notifications/threads/42"),
("fetch", "user"),
("get", reaction_path),
("post", reaction_path, {"content": "+1"}),
("patch", "/api/v1/notifications/threads/42?to-status=read"),
]
@pytest.mark.anyio
async def test_acknowledge_notification_reuses_existing_operator_reaction_on_retry(monkeypatch):
posts = []
async def fake_fetch(path):
if path == "user":
return {"login": "timmy"}
return {
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Pull",
"url": "https://forge.example/api/v1/repos/stackchain/api/pulls/7",
"latest_comment_url": (
"https://forge.example/api/v1/repos/stackchain/api/issues/comments/91"
),
},
}
class Response:
def __init__(self, payload=None):
self.payload = payload
def raise_for_status(self):
return None
def json(self):
return self.payload
class Client:
async def get(self, path, headers):
return Response([{"content": "+1", "user": {"login": "timmy"}}])
async def post(self, path, headers, json):
posts.append((path, json))
return Response()
async def patch(self, path, headers):
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
result = await gitea_proxy.acknowledge_notification(42)
assert result == {"id": 42, "reaction": "existing", "status": "read"}
assert posts == []
@pytest.mark.anyio
async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeypatch):
marked = []
@@ -61,6 +172,33 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
assert marked == [42]
@pytest.mark.anyio
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
calls = []
async def acknowledge(thread_id):
calls.append(thread_id)
return {"id": thread_id, "reaction": "created", "status": "read"}
monkeypatch.setattr(main.gitea_proxy, "acknowledge_notification", acknowledge, raising=False)
monkeypatch.setattr(
main,
"_live_snapshot_value",
{"notifications": [{"id": 42}, {"id": 43}]},
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/api/v1/notifications/42/acknowledge")
invalid = await client.post("/api/v1/notifications/0/acknowledge")
assert response.status_code == 200
assert response.json() == {"id": 42, "reaction": "created", "status": "read"}
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert calls == [42]
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
@pytest.mark.anyio
async def test_snapshot_maintenance_does_not_block_the_event_loop(monkeypatch):
async def mark(_thread_id):