Spaces:
Running
Running
Create tests/test_e2e.py
Browse files- tests/test_e2e.py +323 -0
tests/test_e2e.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""End-to-end test of InfinityChat v2 over HTTP + WebSocket."""
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
import websockets
|
| 9 |
+
|
| 10 |
+
BASE = "http://127.0.0.1:8899"
|
| 11 |
+
WS = "ws://127.0.0.1:8899/ws"
|
| 12 |
+
failures = []
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def check(cond, label, extra=""):
|
| 16 |
+
if cond:
|
| 17 |
+
print(f" ✓ {label}")
|
| 18 |
+
else:
|
| 19 |
+
failures.append(label + (f" — {extra}" if extra else ""))
|
| 20 |
+
print(f" ✗ {label} {extra}")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class Client:
|
| 24 |
+
def __init__(self, name):
|
| 25 |
+
self.name = name
|
| 26 |
+
self.token = None
|
| 27 |
+
self.user = None
|
| 28 |
+
self.ws = None
|
| 29 |
+
self.events = []
|
| 30 |
+
self.message_ids = set()
|
| 31 |
+
|
| 32 |
+
async def connect(self):
|
| 33 |
+
self.ws = await websockets.connect(f"{WS}?token={self.token}")
|
| 34 |
+
ev = await self.expect("connection_established", 5)
|
| 35 |
+
self.user = {"id": ev["user_id"], "username": ev["username"]}
|
| 36 |
+
return ev
|
| 37 |
+
|
| 38 |
+
async def expect(self, mtype, timeout=5):
|
| 39 |
+
while True:
|
| 40 |
+
try:
|
| 41 |
+
msg = json.loads(await asyncio.wait_for(self.ws.recv(), timeout=timeout))
|
| 42 |
+
except asyncio.TimeoutError:
|
| 43 |
+
raise AssertionError(f"{self.name}: timed out waiting for {mtype}")
|
| 44 |
+
self.events.append(msg)
|
| 45 |
+
if msg.get("type") == mtype:
|
| 46 |
+
return msg
|
| 47 |
+
if msg.get("type") == "error":
|
| 48 |
+
raise AssertionError(f"{self.name}: got error: {msg}")
|
| 49 |
+
|
| 50 |
+
def send(self, obj):
|
| 51 |
+
return self.ws.send(json.dumps(obj))
|
| 52 |
+
|
| 53 |
+
async def drain(self, seconds=0.3):
|
| 54 |
+
"""Consume any messages arriving within the window (returns them)."""
|
| 55 |
+
got = []
|
| 56 |
+
try:
|
| 57 |
+
while True:
|
| 58 |
+
msg = json.loads(await asyncio.wait_for(self.ws.recv(), timeout=seconds))
|
| 59 |
+
got.append(msg)
|
| 60 |
+
except (asyncio.TimeoutError, websockets.ConnectionClosed):
|
| 61 |
+
pass
|
| 62 |
+
return got
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
async def wait_events(clients, mtype, timeout=6):
|
| 66 |
+
"""Wait until every client in `clients` has received one `mtype` event."""
|
| 67 |
+
per = {c.name: [] for c in clients}
|
| 68 |
+
deadline = asyncio.get_event_loop().time() + timeout
|
| 69 |
+
while asyncio.get_event_loop().time() < deadline:
|
| 70 |
+
done = True
|
| 71 |
+
for c in clients:
|
| 72 |
+
# take events off the client's socket into per
|
| 73 |
+
try:
|
| 74 |
+
while True:
|
| 75 |
+
msg = json.loads(await asyncio.wait_for(c.ws.recv(), timeout=0.05))
|
| 76 |
+
c.events.append(msg)
|
| 77 |
+
if msg.get("type") == mtype:
|
| 78 |
+
per[c.name].append(msg)
|
| 79 |
+
except asyncio.TimeoutError:
|
| 80 |
+
pass
|
| 81 |
+
except Exception:
|
| 82 |
+
pass
|
| 83 |
+
if not per[c.name]:
|
| 84 |
+
done = False
|
| 85 |
+
if done:
|
| 86 |
+
return per
|
| 87 |
+
await asyncio.sleep(0.05)
|
| 88 |
+
return per
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def main():
|
| 92 |
+
async with httpx.AsyncClient(timeout=15) as http:
|
| 93 |
+
r = await http.get(BASE + "/api/health")
|
| 94 |
+
check(r.status_code == 200 and r.json()["version"] == "2.0.0", "health / version", r.text[:100])
|
| 95 |
+
|
| 96 |
+
# ---- signup ----
|
| 97 |
+
alice = Client("alice")
|
| 98 |
+
bob = Client("bob")
|
| 99 |
+
for c, uname in ((alice, "alice"), (bob, "bob")):
|
| 100 |
+
r = await http.post(f"{BASE}/api/auth/signup?username={uname}&password=password123&display_name={uname.title()}")
|
| 101 |
+
check(r.status_code == 200, f"signup {uname}", r.text[:200])
|
| 102 |
+
c.token = r.json()["token"]
|
| 103 |
+
check(c.token is not None, f"{uname} got token")
|
| 104 |
+
|
| 105 |
+
# signup duplicate should fail
|
| 106 |
+
r = await http.post(f"{BASE}/api/auth/signup?username=alice&password=password123")
|
| 107 |
+
check(r.status_code == 409, "duplicate signup rejected", r.text[:120])
|
| 108 |
+
|
| 109 |
+
# ---- WS connect ----
|
| 110 |
+
ev_a = await alice.connect()
|
| 111 |
+
ev_b = await bob.connect()
|
| 112 |
+
check(len(ev_a["conversations"]) == 1 and ev_a["conversations"][0]["id"] == 1,
|
| 113 |
+
"alice got global conversation in payload")
|
| 114 |
+
check(any(u["id"] == alice.user["id"] for u in ev_b["online_users"]), "bob sees alice online")
|
| 115 |
+
|
| 116 |
+
# ---- global message + receipt round trip ----
|
| 117 |
+
await alice.send({"type": "send_message", "content": "hello everyone", "conversation_id": 1, "client_id": "c1"})
|
| 118 |
+
echo = await alice.expect("new_message")
|
| 119 |
+
check(echo["client_id"] == "c1", "sender ack w/ client_id")
|
| 120 |
+
mid = echo["message"]["id"]
|
| 121 |
+
check(echo["message"]["content"] == "hello everyone", "echo content")
|
| 122 |
+
|
| 123 |
+
# bob receives
|
| 124 |
+
got = await wait_events([bob], "new_message")
|
| 125 |
+
msgs = got["bob"]
|
| 126 |
+
check(len(msgs) >= 1 and msgs[0]["message"]["id"] == mid, "bob received message", str(msgs[:1]))
|
| 127 |
+
|
| 128 |
+
# bob opens tab => instant receipt, alice must hear about it
|
| 129 |
+
await bob.send({"type": "load_messages", "conversation_id": 1, "limit": 50})
|
| 130 |
+
loaded = await bob.expect("messages_loaded")
|
| 131 |
+
check(any(m["id"] == mid for m in loaded["messages"]), "bob loads message")
|
| 132 |
+
await bob.send({"type": "mark_read", "conversation_id": 1, "up_to_message_id": mid})
|
| 133 |
+
read_ev = await alice.expect("message_read", 6)
|
| 134 |
+
check(read_ev["message_id"] == mid, "alice notified instantly that bob read")
|
| 135 |
+
check(read_ev["reader"]["username"] == "bob", "receipt includes reader identity")
|
| 136 |
+
check(isinstance(read_ev["read_at"], int) and read_ev["read_at"] > 1e12, "read_at is ms")
|
| 137 |
+
|
| 138 |
+
# alice reloads -> own message shows read + readers list
|
| 139 |
+
await alice.send({"type": "load_messages", "conversation_id": 1, "limit": 50})
|
| 140 |
+
loaded_a = await alice.expect("messages_loaded")
|
| 141 |
+
mine = [m for m in loaded_a["messages"] if m["id"] == mid][0]
|
| 142 |
+
check(mine["status"] == "read", "status read on reload")
|
| 143 |
+
check(mine["reader_count"] == 1 and mine["readers"][0]["user_id"] == bob.user["id"], "readers attached")
|
| 144 |
+
|
| 145 |
+
# receipts REST endpoint
|
| 146 |
+
r = await http.get(f"{BASE}/api/messages/{mid}/read-receipts", headers={"X-Auth-Token": alice.token})
|
| 147 |
+
check(r.status_code == 200, "receipts REST 200")
|
| 148 |
+
data = r.json()
|
| 149 |
+
check(data["readers"][0]["read_at"] == read_ev["read_at"], "REST receipt has ms time")
|
| 150 |
+
r = await http.get(f"{BASE}/api/messages/{mid}/read-receipts", headers={"X-Auth-Token": bob.token})
|
| 151 |
+
check(r.status_code == 403, "receipts only for author (403 for bob)")
|
| 152 |
+
|
| 153 |
+
# ---- image embed + editing ----
|
| 154 |
+
await alice.send({"type": "send_message", "conversation_id": 1,
|
| 155 |
+
"content": "look <img src='https://example.com/x.png'> at this https://example.com",
|
| 156 |
+
"client_id": "c2"})
|
| 157 |
+
imgecho = await alice.expect("new_message")
|
| 158 |
+
check("img" in imgecho["message"]["content"], "img tag content stored")
|
| 159 |
+
img_id = imgecho["message"]["id"]
|
| 160 |
+
|
| 161 |
+
r = await http.patch(f"{BASE}/api/messages/{img_id}?content=" + "edited%20%2A%2Abold%2A%2A",
|
| 162 |
+
headers={"X-Auth-Token": alice.token})
|
| 163 |
+
check(r.status_code == 200, "REST edit works", r.text[:200])
|
| 164 |
+
ed = await bob.expect("message_edited", 6)
|
| 165 |
+
check(ed["message_id"] == img_id and ed["content"].startswith("edited"), "bob sees edit")
|
| 166 |
+
|
| 167 |
+
# ---- delete reliability (the known bug) ----
|
| 168 |
+
await alice.send({"type": "send_message", "conversation_id": 1, "content": "delete me", "client_id": "c3"})
|
| 169 |
+
dmsg = (await alice.expect("new_message"))["message"]
|
| 170 |
+
# delete via REST (what the new UI does)
|
| 171 |
+
r = await http.delete(f"{BASE}/api/messages/{dmsg['id']}", headers={"X-Auth-Token": alice.token})
|
| 172 |
+
check(r.status_code == 200, "REST delete 200", r.text[:200])
|
| 173 |
+
dele = await bob.expect("message_deleted", 6)
|
| 174 |
+
check(dele["message_id"] == dmsg["id"], "bob got message_deleted broadcast")
|
| 175 |
+
# deleted message gone from DB / loads
|
| 176 |
+
await bob.send({"type": "load_messages", "conversation_id": 1, "limit": 100})
|
| 177 |
+
bl = await bob.expect("messages_loaded")
|
| 178 |
+
check(all(m["id"] != dmsg["id"] for m in bl["messages"]), "deleted msg absent from history")
|
| 179 |
+
# double delete is idempotent-ish (404)
|
| 180 |
+
r = await http.delete(f"{BASE}/api/messages/{dmsg['id']}", headers={"X-Auth-Token": alice.token})
|
| 181 |
+
check(r.status_code == 404, "second delete 404 (no crash)")
|
| 182 |
+
# deleting someone else's message forbidden
|
| 183 |
+
r = await http.delete(f"{BASE}/api/messages/{mid}", headers={"X-Auth-Token": bob.token})
|
| 184 |
+
check(r.status_code == 404 or r.status_code == 403, "can't delete others' msg", r.text[:100])
|
| 185 |
+
from websockets.protocol import State as WsState
|
| 186 |
+
check(alice.ws.state is WsState.OPEN, "alice socket STILL OPEN after delete (bug fixed)")
|
| 187 |
+
check(bob.ws.state is WsState.OPEN, "bob socket STILL OPEN after delete (bug fixed)")
|
| 188 |
+
|
| 189 |
+
# ---- private chats: create, cap of 3, scoped delivery ----
|
| 190 |
+
await alice.send({"type": "create_dm", "user_id": bob.user["id"]})
|
| 191 |
+
dm1_a = await alice.expect("dm_created")
|
| 192 |
+
dm1 = dm1_a["conversation"]
|
| 193 |
+
check(dm1["type"] == "dm" and dm1["peer"]["id"] == bob.user["id"], "dm created for alice")
|
| 194 |
+
dm1_b = await bob.expect("dm_created", 6)
|
| 195 |
+
check(dm1_b["conversation"]["id"] == dm1["id"] and dm1_b["conversation"]["peer"]["id"] == alice.user["id"],
|
| 196 |
+
"bob sees same dm")
|
| 197 |
+
dmid = dm1["id"]
|
| 198 |
+
|
| 199 |
+
# send dm message -> global watchers must NOT see it
|
| 200 |
+
await alice.send({"type": "send_message", "conversation_id": dmid, "content": "psst secret",
|
| 201 |
+
"client_id": "dm1"})
|
| 202 |
+
dm_echo = await alice.expect("new_message")
|
| 203 |
+
leak = await bob.drain(0.4)
|
| 204 |
+
dm_to_bob = [e for e in leak if e.get("type") == "new_message" and e["message"]["conversation_id"] == dmid]
|
| 205 |
+
check(len(dm_to_bob) == 1 and dm_to_bob[0]["message"]["content"] == "psst secret", "dm delivered to bob")
|
| 206 |
+
# Carol should not exist; but verify global conv doesn't include the dm message via fresh load
|
| 207 |
+
# (send a global message and confirm bob's next new_message belongs to conv 1)
|
| 208 |
+
await alice.send({"type": "send_message", "conversation_id": 1, "content": "public hello", "client_id": "c4"})
|
| 209 |
+
got = await wait_events([bob], "new_message")
|
| 210 |
+
last_new = [e for e in got["bob"] if e["message"]["conversation_id"] == 1]
|
| 211 |
+
check(any(e["message"]["content"] == "public hello" for e in last_new), "global msg reaches bob normally")
|
| 212 |
+
|
| 213 |
+
# typing scoping: typing in dm1 must reach bob but not mention global conv
|
| 214 |
+
await alice.send({"type": "typing", "conversation_id": dmid, "is_typing": True})
|
| 215 |
+
ty = await wait_events([bob], "typing_indicator")
|
| 216 |
+
check(ty["bob"] and ty["bob"][0]["conversation_id"] == dmid, "typing scoped to dm")
|
| 217 |
+
|
| 218 |
+
# two more dms, 4th must be rejected
|
| 219 |
+
for i in range(2):
|
| 220 |
+
await alice.send({"type": "create_dm", "user_id": bob.user["id"]})
|
| 221 |
+
await alice.expect("dm_created")
|
| 222 |
+
await bob.expect("dm_created", 6)
|
| 223 |
+
await alice.send({"type": "create_dm", "user_id": bob.user["id"]})
|
| 224 |
+
err = await alice.expect("error")
|
| 225 |
+
check(err["code"] == "DM_FAILED" and "3" in err["message"], "4th dm rejected (cap 3)", err["message"])
|
| 226 |
+
|
| 227 |
+
# REST dm fallback also capped
|
| 228 |
+
r = await http.post(f"{BASE}/api/conversations/dm?user_id={bob.user['id']}", headers={"X-Auth-Token": alice.token})
|
| 229 |
+
check(r.status_code == 400 and "3" in r.json()["detail"], "REST dm cap enforced", r.text[:150])
|
| 230 |
+
|
| 231 |
+
# dm self-chat rejected
|
| 232 |
+
r = await http.post(f"{BASE}/api/conversations/dm?user_id={alice.user['id']}", headers={"X-Auth-Token": alice.token})
|
| 233 |
+
check(r.status_code == 400, "dm with self rejected")
|
| 234 |
+
|
| 235 |
+
# conversations list contains global + 3 dms
|
| 236 |
+
r = await http.get(BASE + "/api/conversations", headers={"X-Auth-Token": alice.token})
|
| 237 |
+
convs = r.json()["conversations"]
|
| 238 |
+
check(len(convs) == 4 and sum(1 for c in convs if c["type"] == "dm") == 3,
|
| 239 |
+
"conversations REST shows 1 global + 3 dm", str([c["type"] for c in convs]))
|
| 240 |
+
|
| 241 |
+
# ---- unread counts for dm (bob hasn't read dm msgs) ----
|
| 242 |
+
r = await http.get(BASE + "/api/conversations", headers={"X-Auth-Token": bob.token})
|
| 243 |
+
bob_convs = r.json()["conversations"]
|
| 244 |
+
for c in bob_convs:
|
| 245 |
+
if c["id"] == dmid:
|
| 246 |
+
check(c["unread_count"] == 1, "bob sees 1 unread in dm1 (he never opened it)",
|
| 247 |
+
f"got {c['unread_count']}")
|
| 248 |
+
# bob opens dm1 & reads
|
| 249 |
+
await bob.send({"type": "load_messages", "conversation_id": dmid, "limit": 50})
|
| 250 |
+
dml = await bob.expect("messages_loaded")
|
| 251 |
+
dm_msgs = dml["messages"]
|
| 252 |
+
check(any(m["content"] == "psst secret" for m in dm_msgs), "bob can load dm history")
|
| 253 |
+
await bob.send({"type": "mark_read", "conversation_id": dmid, "up_to_message_id": max(m["id"] for m in dm_msgs)})
|
| 254 |
+
rread = await alice.expect("message_read", 6)
|
| 255 |
+
check(rread["conversation_id"] == dmid, "dm read receipt scoped to conv")
|
| 256 |
+
r = await http.get(BASE + "/api/conversations", headers={"X-Auth-Token": alice.token})
|
| 257 |
+
bob_conv = next(c for c in r.json()["conversations"] if c["id"] == dmid)
|
| 258 |
+
# bob's unread is reflected for alice? unread is per viewer; fetch as bob
|
| 259 |
+
r = await http.get(BASE + "/api/conversations", headers={"X-Auth-Token": bob.token})
|
| 260 |
+
bob_view = next(c for c in r.json()["conversations"] if c["id"] == dmid)
|
| 261 |
+
check(bob_view["unread_count"] == 0, "dm unread cleared after read")
|
| 262 |
+
check(bob_view["last_message_preview"] == "psst secret", "dm preview present")
|
| 263 |
+
|
| 264 |
+
# dm receipts: only bob in readers; not_read empty
|
| 265 |
+
r = await http.get(f"{BASE}/api/messages/{dm_echo['message']['id']}/read-receipts",
|
| 266 |
+
headers={"X-Auth-Token": alice.token})
|
| 267 |
+
rr = r.json()
|
| 268 |
+
check(rr["is_dm"] and rr["reader_count"] == 1 and rr["not_read"] == [], "dm receipts exact")
|
| 269 |
+
|
| 270 |
+
# ---- password change ----
|
| 271 |
+
r = await http.post(f"{BASE}/api/profile/password?current_password=password123&new_password=password456",
|
| 272 |
+
headers={"X-Auth-Token": bob.token})
|
| 273 |
+
check(r.status_code == 200, "password change ok")
|
| 274 |
+
r = await http.post(f"{BASE}/api/auth/login?username=bob&password=password456")
|
| 275 |
+
check(r.status_code == 200, "login with new password")
|
| 276 |
+
bob.token = r.json()["token"] # login rotates the token
|
| 277 |
+
r = await http.post(f"{BASE}/api/profile/password?current_password=wrong&new_password=password789",
|
| 278 |
+
headers={"X-Auth-Token": bob.token})
|
| 279 |
+
check(r.status_code == 400, "wrong current password rejected")
|
| 280 |
+
|
| 281 |
+
# ---- avatar sniffing (no storage configured) ----
|
| 282 |
+
r = await http.post(f"{BASE}/api/profile/avatar", headers={"X-Auth-Token": alice.token},
|
| 283 |
+
files={"file": ("evil.txt", b"not an image", "text/plain")})
|
| 284 |
+
check(r.status_code == 400, "non-image avatar rejected")
|
| 285 |
+
|
| 286 |
+
# ---- edit/delete of dm content visibility scoping ----
|
| 287 |
+
await alice.send({"type": "send_message", "conversation_id": dmid, "content": "edit me dm", "client_id": "dm2"})
|
| 288 |
+
dm2 = (await alice.expect("new_message"))["message"]
|
| 289 |
+
r = await http.patch(f"{BASE}/api/messages/{dm2['id']}?content=edited-dm", headers={"X-Auth-Token": alice.token})
|
| 290 |
+
check(r.status_code == 200, "dm edit ok")
|
| 291 |
+
e2 = await bob.expect("message_edited", 6)
|
| 292 |
+
check(e2["conversation_id"] == dmid, "dm edit broadcast scoped")
|
| 293 |
+
r = await http.delete(f"{BASE}/api/messages/{dm2['id']}", headers={"X-Auth-Token": alice.token})
|
| 294 |
+
check(r.status_code == 200, "dm delete ok")
|
| 295 |
+
|
| 296 |
+
# ---- pending send dedupe: same client_id twice = 1 message ----
|
| 297 |
+
await alice.send({"type": "send_message", "conversation_id": 1, "content": "dedupe me", "client_id": "dup1"})
|
| 298 |
+
await alice.expect("new_message")
|
| 299 |
+
await alice.send({"type": "send_message", "conversation_id": 1, "content": "dedupe me", "client_id": "dup1"})
|
| 300 |
+
dup_err = await alice.expect("error")
|
| 301 |
+
check(dup_err["code"] == "DUPLICATE", "client_id dedupe returns DUPLICATE")
|
| 302 |
+
await alice.send({"type": "load_messages", "conversation_id": 1, "limit": 100})
|
| 303 |
+
allmsg = (await alice.expect("messages_loaded"))["messages"]
|
| 304 |
+
check(sum(1 for m in allmsg if m["content"] == "dedupe me") == 1, "no duplicate messages stored")
|
| 305 |
+
|
| 306 |
+
# ---- auth edge ----
|
| 307 |
+
r = await http.get(BASE + "/api/auth/verify", headers={"X-Auth-Token": "bogus"})
|
| 308 |
+
check(r.status_code == 401, "bad token rejected")
|
| 309 |
+
|
| 310 |
+
print(f"\nfinished. failures={len(failures)}")
|
| 311 |
+
await alice.ws.close()
|
| 312 |
+
await bob.ws.close()
|
| 313 |
+
return failures
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
if __name__ == "__main__":
|
| 317 |
+
fails = asyncio.run(main())
|
| 318 |
+
if fails:
|
| 319 |
+
print("\nFAILED CHECKS:")
|
| 320 |
+
for f in fails:
|
| 321 |
+
print(" -", f)
|
| 322 |
+
sys.exit(1)
|
| 323 |
+
print("\nALL E2E CHECKS PASSED")
|