From 0355167e02a710c0e40484592ac794810cde3922 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 23:52:13 +0200 Subject: Notifications: one per conversation, none for your own messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things were wrong, and they compounded: a busy chat produced one row per message, muting a group did nothing at all, there was no way to clear the list, and the one person guaranteed to know about a message — its author — was told about it. The author bug was a name mismatch across two processes. The node sent chat_notify without saying who wrote the message, so the hub used the node's own token subject, which is the operator's account. The skip therefore matched the operator and no one else: everybody was notified of their own messages, and the operator was notified of nobody's. The node now names the author and the hub reads that field. Muting lived in the browser's localStorage and nothing ever read it, so the checkbox was decoration. It is a column on group_members now, checked where the notification is created — a notification nobody wants is not written at all. Chat keeps a single row per (user, kind, group) whose date moves and whose read flag clears, so a conversation is one line saying when it last spoke. Clicking it opens the group and dismisses it; joining a group dismisses its invitation; and DELETE /v1/notifications clears the lot. The hub deploy now runs alembic. create_all() only creates missing tables, so group_members.muted never arrived on the running hub and /v1/groups/mine answered 500 — worth catching in the script rather than in a browser. Verified end to end against the deployed hub and node: the author receives nothing, the other member receives exactly one, carrying its group_id. Co-Authored-By: Claude Opus 5 --- .../tests/test_notifications_behaviour.py | 191 +++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 packages/meshbay-hub/tests/test_notifications_behaviour.py (limited to 'packages/meshbay-hub/tests/test_notifications_behaviour.py') diff --git a/packages/meshbay-hub/tests/test_notifications_behaviour.py b/packages/meshbay-hub/tests/test_notifications_behaviour.py new file mode 100644 index 0000000..376b9b0 --- /dev/null +++ b/packages/meshbay-hub/tests/test_notifications_behaviour.py @@ -0,0 +1,191 @@ +""" +How notifications behave, which is mostly about what they do NOT do. + +Three things were wrong and are pinned here: a busy chat produced one row per +message, muting a group did nothing whatsoever (the setting lived in the +browser's localStorage and nothing read it), and there was no way to clear the +list. +""" + +import hashlib +import base64 + +import pytest +from sqlalchemy import select + +from meshbay_hub.db.models import GroupMember, Notification, User + + +def _auth_key(password: str, username: str) -> str: + salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest() + return base64.b64encode( + hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode() + + +async def _user(client, username, password="a-long-enough-passphrase"): + await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@example.com", + "auth_key": _auth_key(password, username)}) + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": _auth_key(password, username)}) + return r.json()["access_token"] + + +@pytest.mark.asyncio +async def test_chat_keeps_one_notification_per_group(client, db_session): + """Forty messages are one line saying when the conversation last spoke.""" + from meshbay_hub.api.notifications import create_notification + + token = await _user(client, "listener") + owner = await _user(client, "talker") + g = await client.post("/v1/groups", json={"name": "busy"}, + headers={"Authorization": f"Bearer {owner}"}) + gid = g.json()["group_id"] + await client.post(f"/v1/groups/{gid}/members/listener", json={}, + headers={"Authorization": f"Bearer {owner}"}) + uid = (await db_session.execute( + select(User.id).where(User.username == "listener"))).scalar_one() + + for i in range(5): + await create_notification(db_session, uid, "chat_message", + f"talker posted in busy ({i})", + group_id=gid, aggregate=True) + await db_session.commit() + + rows = (await db_session.execute( + select(Notification).where(Notification.user_id == uid, + Notification.kind == "chat_message"))).scalars().all() + assert len(rows) == 1, f"{len(rows)} rows for one conversation" + assert rows[0].title.endswith("(4)"), "it should carry the latest message" + assert rows[0].read is False, "a new message makes it unread again" + + +@pytest.mark.asyncio +async def test_muting_a_group_stops_notifications_being_created(client, db_session): + """ + The point of muting is that nothing is created — not that something is + created and then hidden by whoever happens to be rendering it. + """ + from meshbay_hub.api.notifications import create_notification + + token = await _user(client, "quiet") + owner = await _user(client, "noisy") + g = await client.post("/v1/groups", json={"name": "loud"}, + headers={"Authorization": f"Bearer {owner}"}) + gid = g.json()["group_id"] + await client.post(f"/v1/groups/{gid}/members/quiet", json={}, + headers={"Authorization": f"Bearer {owner}"}) + + r = await client.post(f"/v1/groups/{gid}/mute", json={"muted": True}, + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200, r.text + + uid = (await db_session.execute( + select(User.id).where(User.username == "quiet"))).scalar_one() + await db_session.execute( + select(GroupMember).where(GroupMember.user_id == uid)) + + made = await create_notification(db_session, uid, "chat_message", + "noisy posted in loud", + group_id=gid, aggregate=True) + await db_session.commit() + assert made is None, "a muted group must not produce a notification" + + rows = (await db_session.execute( + select(Notification).where(Notification.user_id == uid, + Notification.kind == "chat_message"))).scalars().all() + assert rows == [] + + +@pytest.mark.asyncio +async def test_mute_is_reported_with_the_group_list(client): + """So a second browser shows the same state, which localStorage never did.""" + token = await _user(client, "settings-user") + g = await client.post("/v1/groups", json={"name": "mine"}, + headers={"Authorization": f"Bearer {token}"}) + gid = g.json()["group_id"] + + await client.post(f"/v1/groups/{gid}/mute", json={"muted": True}, + headers={"Authorization": f"Bearer {token}"}) + listing = await client.get("/v1/groups/mine", + headers={"Authorization": f"Bearer {token}"}) + assert listing.json()["groups"][0]["muted"] is True + + +@pytest.mark.asyncio +async def test_purge_clears_the_list(client, db_session): + from meshbay_hub.api.notifications import create_notification + + token = await _user(client, "cluttered") + uid = (await db_session.execute( + select(User.id).where(User.username == "cluttered"))).scalar_one() + for i in range(3): + await create_notification(db_session, uid, "system", f"thing {i}") + await db_session.commit() + + r = await client.delete("/v1/notifications", + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 + assert r.json()["removed"] == 3 + + after = await client.get("/v1/notifications", + headers={"Authorization": f"Bearer {token}"}) + assert after.json()["notifications"] == [] + assert after.json()["unread_count"] == 0 + + +@pytest.mark.asyncio +async def test_only_your_own_notifications_are_purged(client, db_session): + from meshbay_hub.api.notifications import create_notification + + mine = await _user(client, "self") + await _user(client, "other") + other_id = (await db_session.execute( + select(User.id).where(User.username == "other"))).scalar_one() + await create_notification(db_session, other_id, "system", "not yours") + await db_session.commit() + + await client.delete("/v1/notifications", + headers={"Authorization": f"Bearer {mine}"}) + + left = (await db_session.execute( + select(Notification).where(Notification.user_id == other_id))).scalars().all() + assert len(left) == 1, "purging one account emptied another" + + +@pytest.mark.asyncio +async def test_you_are_not_notified_of_your_own_message(client, db_session): + """ + The node reports who wrote the message. The hub used to substitute the + node's own token subject — the operator's account — so the filter matched + the operator and nobody else: everyone was told about their own messages, + and the operator was told about no one's. + """ + from meshbay_hub.api.revocation import _handle_chat_notify + + owner = await _user(client, "operator") + await _user(client, "chatty") + await _user(client, "quiet") + g = await client.post("/v1/groups", json={"name": "room"}, + headers={"Authorization": f"Bearer {owner}"}) + gid = g.json()["group_id"] + for name in ("chatty", "quiet"): + await client.post(f"/v1/groups/{gid}/members/{name}", json={}, + headers={"Authorization": f"Bearer {owner}"}) + + ids = {u.username: u.id for u in (await db_session.execute( + select(User).where(User.username.in_(["operator", "chatty", "quiet"])) + )).scalars().all()} + + await _handle_chat_notify(gid, "chatty", ids["chatty"]) + + async def chat_rows(uid): + return (await db_session.execute( + select(Notification).where(Notification.user_id == uid, + Notification.kind == "chat_message") + )).scalars().all() + + assert await chat_rows(ids["chatty"]) == [], "notified of their own message" + assert len(await chat_rows(ids["quiet"])) == 1 + assert len(await chat_rows(ids["operator"])) == 1, \ + "the operator reads the group too, and was the one being skipped" -- cgit v1.2.3