diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_notifications_behaviour.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_notifications_behaviour.py | 191 |
1 files changed, 191 insertions, 0 deletions
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" |