smodusermc commited on
Commit
f5a89f2
·
verified ·
1 Parent(s): d221625

Create tests/test_e2e.py

Browse files
Files changed (1) hide show
  1. 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")