summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_notification_dismissal.py
blob: d498b4d2ebe998b9141a4dc3cc3c8a9ba71f9654 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
"""
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_test", "admin@x.com")
    set_admin_usernames(["admin_test"])
    admin_token = await _login(client, "admin_test")
    uid = await _register(client, "alice_test", "a@x.com")
    alice_token = await _login(client, "alice_test")
    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")