""" A dismissed notification stays dismissed across a restart. Reported live: clicking a notification navigated to the group and the entry disappeared — the intended behaviour — and it was back on the next launch of the application. Neither half was wrong on its own, which is why it survived. `markRead` in app.js drops the entry locally *and* marks it read on the hub, deliberately: "Reading it is the point of clicking it: it goes, here and in the count, rather than sitting there greyed out." The hub honoured that and persisted it. But the startup fetch asked for `/v1/notifications?limit=20` with no filter, and the endpoint returns read and unread alike, so every dismissed notification came straight back. The feed still carried the fossil of the older intent — `class="notif-item ${n.read ? '' : 'notif-unread'}"`, styling for a read entry rendered greyed out, from before clicking meant dismissing. Filtering the list to unread was the first fix. It corrected what the user saw and left the rows accumulating for nothing, invisible for ever, which is not a resolution so much as a place to hide the disagreement. **Dismissing one now deletes it** — the reasoning `purge_notifications` always carried, applied one at a time: these are signals, not a record. So what this pins is the contract between the two halves: the hub drops the row by either path, old client or new, and the SPA still sends the filter for the case where it is newer than the hub it is talking to. """ import re from pathlib import Path import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from meshbay_common.crypto import pk_to_b64 from meshbay_hub.api.deps import set_admin_usernames STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" APP = STATIC / "app.js" def _gen_user_keys(): return (pk_to_b64(Ed25519PrivateKey.generate().public_key()), pk_to_b64(X25519PrivateKey.generate().public_key())) async def _register(client, username, email): pk_ed, pk_x = _gen_user_keys() r = await client.post("/v1/users/register", json={ "username": username, "email": email, "password": "testpass99", "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x, }) assert r.status_code == 201 return r.json()["user_id"] async def _login(client, username): r = await client.post("/v1/users/login", json={"username": username, "password": "testpass99"}) assert r.status_code == 200 return r.json()["access_token"] async def _one_notification_for_alice(client): """A role change is the cheapest thing that notifies somebody.""" await _register(client, "admin", "admin@x.com") set_admin_usernames(["admin"]) admin_token = await _login(client, "admin") uid = await _register(client, "alice", "a@x.com") alice_token = await _login(client, "alice") await client.patch(f"/v1/admin/users/{uid}", json={"role": "moderator"}, headers={"Authorization": f"Bearer {admin_token}"}) return alice_token @pytest.mark.asyncio async def test_a_read_notification_is_gone_from_what_the_spa_asks_for(client): """The reported bug, as the two requests the application actually makes. The second GET is the next launch. Before the fix it answered with the notification the user had just dismissed. """ token = await _login_and_notify(client) auth = {"Authorization": f"Bearer {token}"} listing = await client.get("/v1/notifications?limit=20&unread_only=true", headers=auth) entries = listing.json()["notifications"] assert len(entries) == 1, "the fixture should have produced exactly one" r = await client.post(f"/v1/notifications/{entries[0]['id']}/read", headers=auth) assert r.status_code == 200 relaunch = await client.get("/v1/notifications?limit=20&unread_only=true", headers=auth) assert relaunch.json()["notifications"] == [], ( "a dismissed notification came back on the next launch") assert relaunch.json()["unread_count"] == 0 @pytest.mark.asyncio async def test_it_is_gone_from_the_unfiltered_list_too(client): """The filter is no longer what makes it disappear — the row is. This assertion is the inverse of what it was. Filtering to unread fixed what the user saw and left the rows behind, invisible for ever; dismissing now deletes, so even a client that asks for everything sees nothing. The filter stays because an interface older than the hub still sends it. """ token = await _login_and_notify(client) auth = {"Authorization": f"Bearer {token}"} nid = (await client.get("/v1/notifications", headers=auth)).json()["notifications"][0]["id"] await client.delete(f"/v1/notifications/{nid}", headers=auth) unfiltered = (await client.get("/v1/notifications?limit=20", headers=auth)).json() assert unfiltered["notifications"] == [] @pytest.mark.asyncio async def test_the_old_read_path_dismisses_too(client): """Version skew is the normal case here, not the exception. The SPA ships inside the desktop package, so a hub is always talking to some interface older than itself — CLAUDE.md records that as a standing consequence of shipping the UI in a package. An old client calling `/read` must stop accumulating rows as well, or the fix only reaches whoever updated. """ token = await _login_and_notify(client) auth = {"Authorization": f"Bearer {token}"} nid = (await client.get("/v1/notifications", headers=auth)).json()["notifications"][0]["id"] r = await client.post(f"/v1/notifications/{nid}/read", headers=auth) assert r.status_code == 200 assert (await client.get("/v1/notifications", headers=auth)).json()["notifications"] == [] # `_one_notification_for_alice` is the setup; this alias keeps the tests reading # as what they are about rather than as their fixture. _login_and_notify = _one_notification_for_alice @pytest.mark.skipif(not APP.exists(), reason="the SPA sources are not available") def test_the_spa_asks_for_unread_only(): """The half a browserless API test cannot reach. Both assertions above pass against a SPA that has dropped the parameter — the bug was never in the endpoint. Reading the source is worth less than exercising it, and here it is the only thing that catches the defect that actually happened. """ src = APP.read_text() fetches = re.findall(r"hubFetch\('(/v1/notifications\?[^']*)'", src) assert fetches, "the notification list fetch is no longer where this reads it" for url in fetches: assert "unread_only=true" in url, ( f"the SPA lists notifications with {url!r} — without unread_only a " "dismissed notification comes back on the next launch") @pytest.mark.skipif(not APP.exists(), reason="the SPA sources are not available") def test_the_feed_does_not_style_a_state_it_can_no_longer_show(): """The fossil that pointed at the bug. `n.read ? '' : 'notif-unread'` is from when a read notification stayed on screen greyed out. Nothing read reaches the feed any more, so the ternary was dead code that described behaviour the application had abandoned — and reading it is what made the two halves' disagreement visible. """ src = APP.read_text() assert "n.read ?" not in src, ( "the feed branches on `read` again; either it is dead code or the " "dismissal contract has changed and this file should say how")