File size: 32,481 Bytes
f5a89f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d94ac43
f5a89f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5841167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5a89f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51bbfca
f5a89f2
51bbfca
f5a89f2
 
 
 
 
 
 
e4b9b5d
f5a89f2
 
 
 
e4b9b5d
 
 
 
f5a89f2
 
e4b9b5d
 
 
 
 
 
 
 
 
 
 
 
 
 
f5a89f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4b9b5d
 
f5a89f2
 
e4b9b5d
 
f5a89f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4b9b5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5a89f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51bbfca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4b9b5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5a89f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
#!/usr/bin/env python3
"""End-to-end test of InfinityChat v2 over HTTP + WebSocket."""
import asyncio
import json
import sys

import httpx
import websockets

BASE = "http://127.0.0.1:8899"
WS = "ws://127.0.0.1:8899/ws"
failures = []


def check(cond, label, extra=""):
    if cond:
        print(f"  ✓ {label}")
    else:
        failures.append(label + (f" — {extra}" if extra else ""))
        print(f"  ✗ {label} {extra}")


class Client:
    def __init__(self, name):
        self.name = name
        self.token = None
        self.user = None
        self.ws = None
        self.events = []
        self.message_ids = set()

    async def connect(self):
        self.ws = await websockets.connect(f"{WS}?token={self.token}")
        ev = await self.expect("connection_established", 5)
        self.user = {"id": ev["user_id"], "username": ev["username"]}
        return ev

    async def expect(self, mtype, timeout=5):
        while True:
            try:
                msg = json.loads(await asyncio.wait_for(self.ws.recv(), timeout=timeout))
            except asyncio.TimeoutError:
                raise AssertionError(f"{self.name}: timed out waiting for {mtype}")
            self.events.append(msg)
            if msg.get("type") == mtype:
                return msg
            if msg.get("type") == "error":
                raise AssertionError(f"{self.name}: got error: {msg}")

    def send(self, obj):
        return self.ws.send(json.dumps(obj))

    async def drain(self, seconds=0.3):
        """Consume any messages arriving within the window (returns them)."""
        got = []
        try:
            while True:
                msg = json.loads(await asyncio.wait_for(self.ws.recv(), timeout=seconds))
                got.append(msg)
        except (asyncio.TimeoutError, websockets.ConnectionClosed):
            pass
        return got


async def wait_events(clients, mtype, timeout=6):
    """Wait until every client in `clients` has received one `mtype` event."""
    per = {c.name: [] for c in clients}
    deadline = asyncio.get_event_loop().time() + timeout
    while asyncio.get_event_loop().time() < deadline:
        done = True
        for c in clients:
            # take events off the client's socket into per
            try:
                while True:
                    msg = json.loads(await asyncio.wait_for(c.ws.recv(), timeout=0.05))
                    c.events.append(msg)
                    if msg.get("type") == mtype:
                        per[c.name].append(msg)
            except asyncio.TimeoutError:
                pass
            except Exception:
                pass
            if not per[c.name]:
                done = False
        if done:
            return per
        await asyncio.sleep(0.05)
    return per


async def main():
    async with httpx.AsyncClient(timeout=15) as http:
        r = await http.get(BASE + "/api/health")
        check(r.status_code == 200 and r.json()["version"] == "3.0.0", "health / version", r.text[:100])

        # ---- signup ----
        alice = Client("alice")
        bob = Client("bob")
        for c, uname in ((alice, "alice"), (bob, "bob")):
            r = await http.post(f"{BASE}/api/auth/signup?username={uname}&password=password123&display_name={uname.title()}")
            check(r.status_code == 200, f"signup {uname}", r.text[:200])
            c.token = r.json()["token"]
            check(c.token is not None, f"{uname} got token")

        # signup duplicate should fail
        r = await http.post(f"{BASE}/api/auth/signup?username=alice&password=password123")
        check(r.status_code == 409, "duplicate signup rejected", r.text[:120])

        # ---- WS connect ----
        ev_a = await alice.connect()
        ev_b = await bob.connect()
        check(len(ev_a["conversations"]) == 1 and ev_a["conversations"][0]["id"] == 1,
              "alice got global conversation in payload")
        check(any(u["id"] == alice.user["id"] for u in ev_b["online_users"]), "bob sees alice online")

        # ---- live presence stays in sync after profile changes ----
        r = await http.patch(f"{BASE}/api/profile?display_name=AliceX",
                             headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200, "alice profile update ok")
        pv = await bob.expect("profile_updated", 6)
        check(pv["user_id"] == alice.user["id"] and pv["display_name"] == "AliceX",
              "bob receives live profile update")
        await bob.send({"type": "get_online_users"})
        online = await bob.expect("online_users", 6)
        alice_online = next((u for u in online["users"] if u["id"] == alice.user["id"]), None)
        check(alice_online is not None and alice_online["display_name"] == "AliceX",
              "online users list reflects updated profile")

        # ---- disconnect must clear presence on other clients ----
        await alice.drain(0.4)  # clear the earlier "bob online" event
        await bob.ws.close()
        off = await alice.expect("user_status", 6)
        check(off["user_id"] == bob.user["id"] and off["status"] == "offline",
              "alice is notified when bob disconnects")
        # reconnect bob so the rest of the suite can keep using him
        await bob.connect()

        # ---- global message + receipt round trip ----
        await alice.send({"type": "send_message", "content": "hello everyone", "conversation_id": 1, "client_id": "c1"})
        echo = await alice.expect("new_message")
        check(echo["client_id"] == "c1", "sender ack w/ client_id")
        mid = echo["message"]["id"]
        check(echo["message"]["content"] == "hello everyone", "echo content")

        # bob receives
        got = await wait_events([bob], "new_message")
        msgs = got["bob"]
        check(len(msgs) >= 1 and msgs[0]["message"]["id"] == mid, "bob received message", str(msgs[:1]))

        # bob opens tab => instant receipt, alice must hear about it
        await bob.send({"type": "load_messages", "conversation_id": 1, "limit": 50})
        loaded = await bob.expect("messages_loaded")
        check(any(m["id"] == mid for m in loaded["messages"]), "bob loads message")
        await bob.send({"type": "mark_read", "conversation_id": 1, "up_to_message_id": mid})
        read_ev = await alice.expect("message_read", 6)
        check(read_ev["message_id"] == mid, "alice notified instantly that bob read")
        check(read_ev["reader"]["username"] == "bob", "receipt includes reader identity")
        check(isinstance(read_ev["read_at"], int) and read_ev["read_at"] > 1e12, "read_at is ms")

        # alice reloads -> own message shows read + readers list
        await alice.send({"type": "load_messages", "conversation_id": 1, "limit": 50})
        loaded_a = await alice.expect("messages_loaded")
        mine = [m for m in loaded_a["messages"] if m["id"] == mid][0]
        check(mine["status"] == "read", "status read on reload")
        check(mine["reader_count"] == 1 and mine["readers"][0]["user_id"] == bob.user["id"], "readers attached")

        # receipts REST endpoint
        r = await http.get(f"{BASE}/api/messages/{mid}/read-receipts", headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200, "receipts REST 200")
        data = r.json()
        check(data["readers"][0]["read_at"] == read_ev["read_at"], "REST receipt has ms time")
        r = await http.get(f"{BASE}/api/messages/{mid}/read-receipts", headers={"X-Auth-Token": bob.token})
        check(r.status_code == 403, "receipts only for author (403 for bob)")

        # ---- image embed + editing ----
        await alice.send({"type": "send_message", "conversation_id": 1,
                          "content": "look <img src='https://example.com/x.png'> at this https://example.com",
                          "client_id": "c2"})
        imgecho = await alice.expect("new_message")
        check("img" in imgecho["message"]["content"], "img tag content stored")
        img_id = imgecho["message"]["id"]

        r = await http.patch(f"{BASE}/api/messages/{img_id}?content=" + "edited%20%2A%2Abold%2A%2A",
                             headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200, "REST edit works", r.text[:200])
        ed = await bob.expect("message_edited", 6)
        check(ed["message_id"] == img_id and ed["content"].startswith("edited"), "bob sees edit")

        # ---- delete reliability (the known bug) ----
        await alice.send({"type": "send_message", "conversation_id": 1, "content": "delete me", "client_id": "c3"})
        dmsg = (await alice.expect("new_message"))["message"]
        # delete via REST (what the new UI does)
        r = await http.delete(f"{BASE}/api/messages/{dmsg['id']}", headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200, "REST delete 200", r.text[:200])
        dele = await bob.expect("message_deleted", 6)
        check(dele["message_id"] == dmsg["id"], "bob got message_deleted broadcast")
        # deleted message gone from DB / loads
        await bob.send({"type": "load_messages", "conversation_id": 1, "limit": 100})
        bl = await bob.expect("messages_loaded")
        check(all(m["id"] != dmsg["id"] for m in bl["messages"]), "deleted msg absent from history")
        # double delete is idempotent (200 "already_deleted")
        r = await http.delete(f"{BASE}/api/messages/{dmsg['id']}", headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200, "second delete is a no-op (no crash)")
        # deleting someone else's message forbidden
        r = await http.delete(f"{BASE}/api/messages/{mid}", headers={"X-Auth-Token": bob.token})
        check(r.status_code == 404 or r.status_code == 403, "can't delete others' msg", r.text[:100])
        from websockets.protocol import State as WsState
        check(alice.ws.state is WsState.OPEN, "alice socket STILL OPEN after delete (bug fixed)")
        check(bob.ws.state is WsState.OPEN, "bob socket STILL OPEN after delete (bug fixed)")

        # ---- private chats: create, require acceptance, cap of 3, scoped delivery ----
        await alice.send({"type": "create_dm", "user_id": bob.user["id"]})
        dm1_a = await alice.expect("dm_created")
        dm1 = dm1_a["conversation"]
        check(dm1["type"] == "dm" and dm1["peer"]["id"] == bob.user["id"], "dm created for alice")
        dm1_b = await bob.expect("conversation_updated", 6)
        check(dm1_b["conversation"]["id"] == dm1["id"] and dm1_b["conversation"]["peer"]["id"] == alice.user["id"]
              and dm1_b["conversation"]["is_pending"],
              "bob sees pending private chat invite")
        dmid = dm1["id"]

        # A pending DM invitee cannot send or load until accepting.
        await bob.send({"type": "send_message", "conversation_id": dmid,
                        "content": "before accept", "client_id": "bob-pre-accept"})
        pre_err = await bob.expect("error")
        check(pre_err.get("code") == "FORBIDDEN", "pending invitee cannot send", pre_err.get("message", ""))
        r = await http.post(f"{BASE}/api/conversations/{dmid}/accept", headers={"X-Auth-Token": bob.token})
        check(r.status_code == 200 and r.json()["conversation"]["is_pending"] is False,
              "bob accepts private chat invite")
        accept_a = await alice.expect("conversation_updated", 6)
        check(accept_a["conversation"]["id"] == dmid and accept_a["conversation"]["is_pending"] is False,
              "alice is notified bob accepted")
        await alice.drain(0.2)
        await bob.drain(0.2)

        # send dm message -> global watchers must NOT see it
        await alice.send({"type": "send_message", "conversation_id": dmid, "content": "psst secret",
                          "client_id": "dm1"})
        dm_echo = await alice.expect("new_message")
        leak = await bob.drain(0.4)
        dm_to_bob = [e for e in leak if e.get("type") == "new_message" and e["message"]["conversation_id"] == dmid]
        check(len(dm_to_bob) == 1 and dm_to_bob[0]["message"]["content"] == "psst secret", "dm delivered to bob")
        # Carol should not exist; but verify global conv doesn't include the dm message via fresh load
        # (send a global message and confirm bob's next new_message belongs to conv 1)
        await alice.send({"type": "send_message", "conversation_id": 1, "content": "public hello", "client_id": "c4"})
        got = await wait_events([bob], "new_message")
        last_new = [e for e in got["bob"] if e["message"]["conversation_id"] == 1]
        check(any(e["message"]["content"] == "public hello" for e in last_new), "global msg reaches bob normally")

        # typing scoping: typing in dm1 must reach bob but not mention global conv
        await alice.send({"type": "typing", "conversation_id": dmid, "is_typing": True})
        ty = await wait_events([bob], "typing_indicator")
        check(ty["bob"] and ty["bob"][0]["conversation_id"] == dmid, "typing scoped to dm")

        # two more dms (pending for bob), 4th must be rejected
        extra_dm_ids = []
        for i in range(2):
            await alice.send({"type": "create_dm", "user_id": bob.user["id"]})
            extra_dm_ids.append((await alice.expect("dm_created"))["conversation"]["id"])
            await bob.expect("conversation_updated", 6)
        await alice.send({"type": "create_dm", "user_id": bob.user["id"]})
        err = await alice.expect("error")
        check(err["code"] == "DM_FAILED" and "3" in err["message"], "4th dm rejected (cap 3)", err["message"])

        # REST dm fallback also capped
        r = await http.post(f"{BASE}/api/conversations/dm?user_id={bob.user['id']}", headers={"X-Auth-Token": alice.token})
        check(r.status_code == 400 and "3" in r.json()["detail"], "REST dm cap enforced", r.text[:150])

        # dm self-chat rejected
        r = await http.post(f"{BASE}/api/conversations/dm?user_id={alice.user['id']}", headers={"X-Auth-Token": alice.token})
        check(r.status_code == 400, "dm with self rejected")

        # conversations list contains global + 3 dms
        r = await http.get(BASE + "/api/conversations", headers={"X-Auth-Token": alice.token})
        convs = r.json()["conversations"]
        check(len(convs) == 4 and sum(1 for c in convs if c["type"] == "dm") == 3,
              "conversations REST shows 1 global + 3 dm", str([c["type"] for c in convs]))

        # ---- unread counts for dm (bob hasn't read dm msgs) ----
        r = await http.get(BASE + "/api/conversations", headers={"X-Auth-Token": bob.token})
        bob_convs = r.json()["conversations"]
        for c in bob_convs:
            if c["id"] == dmid:
                check(c["unread_count"] == 1, "bob sees 1 unread in dm1 (he never opened it)",
                      f"got {c['unread_count']}")
        # bob opens dm1 & reads
        await bob.send({"type": "load_messages", "conversation_id": dmid, "limit": 50})
        dml = await bob.expect("messages_loaded")
        dm_msgs = dml["messages"]
        check(any(m["content"] == "psst secret" for m in dm_msgs), "bob can load dm history")
        await bob.send({"type": "mark_read", "conversation_id": dmid, "up_to_message_id": max(m["id"] for m in dm_msgs)})
        rread = await alice.expect("message_read", 6)
        check(rread["conversation_id"] == dmid, "dm read receipt scoped to conv")
        r = await http.get(BASE + "/api/conversations", headers={"X-Auth-Token": alice.token})
        bob_conv = next(c for c in r.json()["conversations"] if c["id"] == dmid)
        # bob's unread is reflected for alice? unread is per viewer; fetch as bob
        r = await http.get(BASE + "/api/conversations", headers={"X-Auth-Token": bob.token})
        bob_view = next(c for c in r.json()["conversations"] if c["id"] == dmid)
        check(bob_view["unread_count"] == 0, "dm unread cleared after read")
        check(bob_view["last_message_preview"] == "psst secret", "dm preview present")

        # dm receipts: only bob in readers; not_read empty
        r = await http.get(f"{BASE}/api/messages/{dm_echo['message']['id']}/read-receipts",
                           headers={"X-Auth-Token": alice.token})
        rr = r.json()
        check(rr["is_dm"] and rr["reader_count"] == 1 and rr["not_read"] == [], "dm receipts exact")

        # ---- DM invites can be declined and re-invites require acceptance again ----
        reinv_id = extra_dm_ids[0]
        r = await http.post(f"{BASE}/api/conversations/{reinv_id}/reject",
                            headers={"X-Auth-Token": bob.token})
        check(r.status_code == 200 and r.json()["status"] == "rejected", "pending invitee declines dm")
        await alice.drain(0.3)  # alice is told the invite was declined
        await bob.drain(0.2)
        await alice.send({"type": "create_dm", "user_id": bob.user["id"]})
        re_created = await alice.expect("dm_created")
        check(re_created["conversation"]["id"] == reinv_id,
              "dm re-invite reuses the existing conversation")
        re_pend = await bob.expect("conversation_updated", 6)
        check(re_pend["conversation"]["id"] == reinv_id and re_pend["conversation"]["is_pending"],
              "dm re-invite requires acceptance again")
        r = await http.post(f"{BASE}/api/conversations/{reinv_id}/accept",
                            headers={"X-Auth-Token": bob.token})
        check(r.status_code == 200 and r.json()["conversation"]["is_pending"] is False,
              "bob accepts the dm re-invite")
        await alice.expect("conversation_updated", 6)
        await alice.drain(0.2)
        await bob.drain(0.2)

        # ---- password change ----
        r = await http.post(f"{BASE}/api/profile/password?current_password=password123&new_password=password456",
                            headers={"X-Auth-Token": bob.token})
        check(r.status_code == 200, "password change ok")
        r = await http.post(f"{BASE}/api/auth/login?username=bob&password=password456")
        check(r.status_code == 200, "login with new password")
        bob.token = r.json()["token"]  # login rotates the token
        r = await http.post(f"{BASE}/api/profile/password?current_password=wrong&new_password=password789",
                            headers={"X-Auth-Token": bob.token})
        check(r.status_code == 400, "wrong current password rejected")

        # ---- avatar sniffing (no storage configured) ----
        r = await http.post(f"{BASE}/api/profile/avatar", headers={"X-Auth-Token": alice.token},
                            files={"file": ("evil.txt", b"not an image", "text/plain")})
        check(r.status_code == 400, "non-image avatar rejected")

        # ---- edit/delete of dm content visibility scoping ----
        await alice.send({"type": "send_message", "conversation_id": dmid, "content": "edit me dm", "client_id": "dm2"})
        dm2 = (await alice.expect("new_message"))["message"]
        r = await http.patch(f"{BASE}/api/messages/{dm2['id']}?content=edited-dm", headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200, "dm edit ok")
        e2 = await bob.expect("message_edited", 6)
        check(e2["conversation_id"] == dmid, "dm edit broadcast scoped")
        r = await http.delete(f"{BASE}/api/messages/{dm2['id']}", headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200, "dm delete ok")

        # ---- pending send dedupe: same client_id twice = 1 message ----
        await alice.send({"type": "send_message", "conversation_id": 1, "content": "dedupe me", "client_id": "dup1"})
        await alice.expect("new_message")
        await alice.send({"type": "send_message", "conversation_id": 1, "content": "dedupe me", "client_id": "dup1"})
        dup_err = await alice.expect("error")
        check(dup_err["code"] == "DUPLICATE", "client_id dedupe returns DUPLICATE")
        await alice.send({"type": "load_messages", "conversation_id": 1, "limit": 100})
        allmsg = (await alice.expect("messages_loaded"))["messages"]
        check(sum(1 for m in allmsg if m["content"] == "dedupe me") == 1, "no duplicate messages stored")

        # ---- group chats, invites, blocking and renaming ----
        await alice.drain(0.4)
        await bob.drain(0.4)
        r = await http.post(f"{BASE}/api/conversations/group?name=Group Test&member_ids={bob.user['id']}",
                            headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200, "group create ok", r.text[:200])
        group = r.json()["conversation"]
        gid = group["id"]
        check(group["is_group"] is True and group["role"] == "owner", "creator is group owner")

        # bob receives a pending invite and must accept before chatting
        pending = await bob.expect("conversation_updated", 6)
        check(pending["conversation"]["id"] == gid and pending["conversation"]["is_pending"],
              "bob gets pending group invite")
        r = await http.post(f"{BASE}/api/conversations/{gid}/accept", headers={"X-Auth-Token": bob.token})
        check(r.status_code == 200 and r.json()["conversation"]["is_pending"] is False,
              "bob accepts invite")
        await alice.drain(0.3)
        await bob.drain(0.3)

        # rename the group; everyone else sees it
        r = await http.patch(f"{BASE}/api/conversations/{gid}?name=Renamed Group",
                             headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200 and r.json()["conversation"]["custom_name"] == "Renamed Group",
              "group rename ok")
        renamed = await bob.expect("conversation_updated", 6)
        check(renamed["conversation"]["custom_name"] == "Renamed Group", "bob sees renamed group")

        # non-owner leaves; owner can delete afterwards
        r = await http.delete(f"{BASE}/api/conversations/{gid}", headers={"X-Auth-Token": bob.token})
        check(r.status_code == 200 and r.json()["status"] == "left", "group member can leave")
        r = await http.delete(f"{BASE}/api/conversations/{gid}", headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200 and r.json()["status"] == "deleted", "owner can delete group")

        # ---- group invites stay acceptable and pending users cannot act ----
        charlie = Client("charlie")
        r = await http.post(f"{BASE}/api/auth/signup?username=charlie&password=password123&display_name=Charlie")
        check(r.status_code == 200, "signup charlie", r.text[:200])
        charlie.token = r.json()["token"]
        charlie.user = r.json()["user"]

        await alice.drain(0.3)
        await bob.drain(0.3)
        r = await http.post(
            f"{BASE}/api/conversations/group?name=Invite Group&member_ids={bob.user['id']},{charlie.user['id']}",
            headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200, "group with two invitees created", r.text[:200])
        g2id = r.json()["conversation"]["id"]

        pend = await bob.expect("conversation_updated", 6)
        check(pend["conversation"]["id"] == g2id and pend["conversation"]["is_pending"],
              "second group invite arrives as pending")

        # A pending invitee cannot leave, rename, send, or add members.
        r = await http.delete(f"{BASE}/api/conversations/{g2id}", headers={"X-Auth-Token": bob.token})
        check(r.status_code == 403, "pending user cannot leave", r.text[:120])
        r = await http.patch(f"{BASE}/api/conversations/{g2id}?name=Nope", headers={"X-Auth-Token": bob.token})
        check(r.status_code == 403, "pending user cannot rename", r.text[:120])
        await bob.send({"type": "send_message", "conversation_id": g2id,
                        "content": "should not send", "client_id": "pending-send"})
        send_err = await bob.expect("error", 6)
        check(send_err.get("code") == "FORBIDDEN", "pending user cannot send", send_err.get("message", ""))
        r = await http.post(f"{BASE}/api/conversations/{g2id}/members?member_ids={alice.user['id']}",
                            headers={"X-Auth-Token": bob.token})
        check(r.status_code == 403, "pending user cannot add members", r.text[:120])

        # Rejecting removes the pending row.
        r = await http.post(f"{BASE}/api/conversations/{g2id}/reject", headers={"X-Auth-Token": bob.token})
        check(r.status_code == 200 and r.json()["status"] == "rejected", "pending user can reject invite")

        # A future re-invite must go through acceptance again.
        await alice.drain(0.3)
        await bob.drain(0.3)
        r = await http.post(f"{BASE}/api/conversations/{g2id}/members?member_ids={bob.user['id']}",
                            headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200, "owner can re-invite after reject", r.text[:200])
        re_pend = await bob.expect("conversation_updated", 6)
        check(re_pend["conversation"]["id"] == g2id and re_pend["conversation"]["is_pending"],
              "re-invite needs acceptance again")
        r = await http.post(f"{BASE}/api/conversations/{g2id}/accept", headers={"X-Auth-Token": bob.token})
        check(r.status_code == 200 and r.json()["conversation"]["is_pending"] is False,
              "bob accepts the re-invite")
        r = await http.post(f"{BASE}/api/conversations/{g2id}/accept", headers={"X-Auth-Token": charlie.token})
        check(r.status_code == 200 and r.json()["conversation"]["is_pending"] is False,
              "charlie accepts its own invite independently")
        await alice.drain(0.3)
        await bob.drain(0.3)

        # Owner can also delete a group that still has pending/unopened invites.
        r = await http.delete(f"{BASE}/api/conversations/{g2id}", headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200 and r.json()["status"] == "deleted", "owner can delete group with invites")

        # blocking hides all existing chats but does not delete them
        pre_block = await http.get(f"{BASE}/api/conversations", headers={"X-Auth-Token": alice.token})
        check(any(c["id"] == dmid for c in pre_block.json()["conversations"]), "dm exists before block")
        r = await http.post(f"{BASE}/api/users/{bob.user['id']}/block",
                            headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200, "alice blocks bob")
        blocked_event = await bob.expect("block_changed", 6)
        check(blocked_event["blocked"] is True and blocked_event["blocker_id"] == alice.user["id"],
              "blocked user is notified")
        r = await http.get(f"{BASE}/api/conversations", headers={"X-Auth-Token": alice.token})
        check(all(c["id"] != dmid for c in r.json()["conversations"]), "blocked dm hidden for blocker")
        r = await http.get(f"{BASE}/api/conversations", headers={"X-Auth-Token": bob.token})
        check(all(c["id"] != dmid for c in r.json()["conversations"]), "blocked dm hidden for blocked user")
        r = await http.post(f"{BASE}/api/conversations/dm?user_id={alice.user['id']}",
                            headers={"X-Auth-Token": bob.token})
        check(r.status_code == 403, "blocked user cannot start new dm")

        # the global room must stay usable while a pair is blocked
        await alice.drain(0.3)
        await bob.drain(0.3)
        await alice.send({"type": "send_message", "content": "global while blocked",
                          "conversation_id": 1, "client_id": "gblock1"})
        echo = await alice.expect("new_message")
        check(echo["message"]["content"] == "global while blocked",
              "blocker can still use the global room")
        got = await wait_events([bob], "new_message")
        check(any(m["message"].get("content") == "global while blocked" for m in got["bob"]),
              "blocked user still receives global-room messages", str(got["bob"][:1]))
        await alice.drain(0.2)
        await bob.drain(0.2)
        await bob.send({"type": "send_message", "content": "from blocked user in global",
                        "conversation_id": 1, "client_id": "gblock2"})
        echo = await bob.expect("new_message")
        check(echo["message"]["content"] == "from blocked user in global",
              "blocked user can still send in the global room")
        got = await wait_events([alice], "new_message")
        check(any(m["message"].get("content") == "from blocked user in global" for m in got["alice"]),
              "blocker still receives messages from blocked user in global room", str(got["alice"][:1]))

        # unblock restores the hidden chat (still stored, never deleted)
        r = await http.delete(f"{BASE}/api/users/{bob.user['id']}/block",
                              headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200, "unblock ok")
        await alice.drain(0.3)
        await bob.drain(0.3)
        after = await http.get(f"{BASE}/api/conversations", headers={"X-Auth-Token": alice.token})
        check(any(c["id"] == dmid for c in after.json()["conversations"]),
              "hidden dm returns after unblock")

        # ---- privacy: deleting a private chat hides it for that user ----
        await alice.drain(0.3)
        await bob.drain(0.3)
        r = await http.delete(f"{BASE}/api/conversations/{dmid}", headers={"X-Auth-Token": alice.token})
        check(r.status_code == 200 and r.json()["status"] == "hidden", "alice hides dm", r.text[:160])
        left_a = await alice.expect("conversation_left", 6)
        check(left_a["user_id"] == alice.user["id"] and left_a["reason"] == "deleted",
              "alice gets conversation_left for own hide")
        left_b = await bob.expect("conversation_left", 6)
        check(left_b["user_id"] == alice.user["id"] and left_b["reason"] == "deleted"
              and left_b["conversation"]["id"] == dmid
              and left_b["conversation"]["peer_removed"] is True,
              "bob is told alice no longer wants to chat")
        r = await http.get(f"{BASE}/api/conversations", headers={"X-Auth-Token": alice.token})
        check(all(c["id"] != dmid for c in r.json()["conversations"]),
              "hidden dm omitted from alice's list")
        r = await http.get(f"{BASE}/api/conversations", headers={"X-Auth-Token": bob.token})
        check(any(c["id"] == dmid for c in r.json()["conversations"]),
              "hidden dm still present for bob")

        # bob can still send, but the hidden user never receives it
        await bob.send({"type": "send_message", "conversation_id": dmid,
                        "content": "after alice hid", "client_id": "after-hide"})
        echo = await bob.expect("new_message")
        check(echo["message"]["content"] == "after alice hid", "bob can still send into the dm")
        leak = await alice.drain(0.5)
        check(not any(e.get("type") == "new_message" and e.get("message", {}).get("conversation_id") == dmid
                      for e in leak), "hidden user does not receive dm messages")

        # bob deletes too -> chat is removed for everyone
        r = await http.delete(f"{BASE}/api/conversations/{dmid}", headers={"X-Auth-Token": bob.token})
        check(r.status_code == 200 and r.json()["status"] == "deleted", "bob deletes dm too", r.text[:160])
        del_b = await bob.expect("conversation_deleted", 6)
        del_a = await alice.expect("conversation_deleted", 6)
        check(del_b["conversation_id"] == dmid and del_a["conversation_id"] == dmid,
              "both users are notified when the dm is fully deleted")
        r = await http.get(f"{BASE}/api/conversations", headers={"X-Auth-Token": alice.token})
        check(all(c["id"] != dmid for c in r.json()["conversations"]), "deleted dm gone for alice")
        r = await http.get(f"{BASE}/api/conversations", headers={"X-Auth-Token": bob.token})
        check(all(c["id"] != dmid for c in r.json()["conversations"]), "deleted dm gone for bob")

        # ---- auth edge ----
        r = await http.get(BASE + "/api/auth/verify", headers={"X-Auth-Token": "bogus"})
        check(r.status_code == 401, "bad token rejected")

        print(f"\nfinished. failures={len(failures)}")
        await alice.ws.close()
        await bob.ws.close()
        return failures


if __name__ == "__main__":
    fails = asyncio.run(main())
    if fails:
        print("\nFAILED CHECKS:")
        for f in fails:
            print("  -", f)
        sys.exit(1)
    print("\nALL E2E CHECKS PASSED")