InfinityChat / tests /test_e2e.py
smodusermc's picture
Update tests/test_e2e.py
d94ac43 verified
Raw History Blame Contribute Delete
32.5 kB
#!/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")