eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import XCTest
2@testable import LChatCore
3
4// Cross-platform event contract tests: ensures MessagePayload deserialization
5// and message/reaction/edit/delete handling matches the web (chat.js) side.
6// Vectors sourced from test/event-vectors.json.
7
8final class EventVectorTests: XCTestCase {
9 // Helper: load the shared vector file
10 func loadVectors() -> [String: Any]? {
11 guard let path = Bundle(for: type(of: self)).path(forResource: "event-vectors", ofType: "json") else {
12 return nil
13 }
14 guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
15 return nil
16 }
17 return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
18 }
19
20 func testPlainTextMessage() {
21 // Plain text message with no attachments or reply.
22 let payload = MessagePayload(
23 type: nil,
24 uid: "user-alice-1",
25 name: "Alice",
26 text: "Hello everyone",
27 ts: 1700000000000,
28 action: nil,
29 attachments: nil,
30 replyTo: nil
31 )
32
33 XCTAssertEqual(payload.name, "Alice", "name should be set")
34 XCTAssertEqual(payload.text, "Hello everyone", "text should be set")
35 XCTAssertEqual(payload.uid, "user-alice-1", "uid should be set")
36 XCTAssertEqual(payload.ts, 1700000000000, "ts should be set (ms)")
37 XCTAssertNil(payload.type, "type should be nil (legacy chat message)")
38 XCTAssertNil(payload.action, "action should be nil")
39 XCTAssertNil(payload.attachments, "attachments should be nil")
40 XCTAssertNil(payload.replyTo, "replyTo should be nil")
41 XCTAssertTrue(payload.isRenderableChat, "untyped messages are renderable")
42 }
43
44 func testMessageWithAttachments() {
45 let attachments = [
46 Attachment(
47 id: "blob-img-1",
48 name: "photo.jpg",
49 type: "image/jpeg",
50 w: 1920,
51 h: 1080,
52 size: 524288,
53 poster: nil,
54 dur: nil
55 ),
56 Attachment(
57 id: "blob-file-1",
58 name: "report.pdf",
59 type: "application/pdf",
60 w: nil,
61 h: nil,
62 size: 2097152,
63 poster: nil,
64 dur: nil
65 ),
66 ]
67
68 let payload = MessagePayload(
69 type: nil,
70 uid: "user-bob-1",
71 name: "Bob",
72 text: "Check these out",
73 ts: 1700000001000,
74 action: nil,
75 attachments: attachments,
76 replyTo: nil
77 )
78
79 XCTAssertEqual(payload.attachments?.count, 2, "should have two attachments")
80 let img = payload.attachments?[0]
81 XCTAssertEqual(img?.id, "blob-img-1", "image id should match")
82 XCTAssertEqual(img?.name, "photo.jpg", "image name should match")
83 XCTAssertTrue(img?.isImage ?? false, "image type should be detected")
84 XCTAssertFalse(img?.isVideo ?? false, "image should not be a video")
85 XCTAssertEqual(img?.w, 1920, "image width should be set")
86
87 let file = payload.attachments?[1]
88 XCTAssertEqual(file?.type, "application/pdf", "file type should match")
89 XCTAssertFalse(file?.isImage ?? false, "file should not be an image")
90 }
91
92 func testMessageWithReply() {
93 let replyRef = ReplyRef(id: 12345, name: "Alice", snippet: "Hello everyone")
94 let payload = MessagePayload(
95 type: nil,
96 uid: "user-charlie-1",
97 name: "Charlie",
98 text: "I agree!",
99 ts: 1700000002000,
100 action: nil,
101 attachments: nil,
102 replyTo: replyRef
103 )
104
105 XCTAssertNotNil(payload.replyTo, "replyTo should be present")
106 XCTAssertEqual(payload.replyTo?.id, 12345, "reply id should match")
107 XCTAssertEqual(payload.replyTo?.name, "Alice", "reply name should match")
108 XCTAssertEqual(payload.replyTo?.snippet, "Hello everyone", "reply snippet should match")
109 }
110
111 func testActionLine() {
112 // A /me-style action line (e.g., from a to-do app).
113 let payload = MessagePayload(
114 type: nil,
115 uid: "app:todo",
116 name: "Todo Bot",
117 text: "added 'milk'",
118 ts: 1700000003000,
119 action: true,
120 attachments: nil,
121 replyTo: nil
122 )
123
124 XCTAssertTrue(payload.action ?? false, "action flag should be set")
125 XCTAssertEqual(payload.uid, "app:todo", "app uid should have app: prefix")
126 }
127
128 func testMessageWithVideo() {
129 let video = Attachment(
130 id: "blob-vid-1",
131 name: "clip.mp4",
132 type: "video/mp4",
133 w: 1280,
134 h: 720,
135 size: 5242880,
136 poster: "blob-vid-1-poster",
137 dur: 45.5
138 )
139
140 let payload = MessagePayload(
141 type: nil,
142 uid: "user-diana-1",
143 name: "Diana",
144 text: "Check this video",
145 ts: 1700000004000,
146 action: nil,
147 attachments: [video],
148 replyTo: nil
149 )
150
151 let att = payload.attachments?[0]
152 XCTAssertTrue(att?.isVideo ?? false, "video type should be detected")
153 XCTAssertFalse(att?.isImage ?? false, "video should not be an image")
154 XCTAssertEqual(att?.dur, 45.5, "video duration should be set")
155 XCTAssertEqual(att?.poster, "blob-vid-1-poster", "video poster should be set")
156 }
157
158 func testReactionEvent() {
159 // Reactions are now encrypted events (type="reaction") that fold into message reactions.
160 let reactionAdd = MessagePayload(
161 type: "reaction",
162 uid: "user-alice-1",
163 name: "Alice",
164 text: nil,
165 ts: 1700000010000,
166 action: nil,
167 attachments: nil,
168 replyTo: nil,
169 targetSid: 12345,
170 targetUid: "user-eve-1",
171 emoji: "πŸ‘",
172 on: true,
173 targetText: "buy milk"
174 )
175
176 XCTAssertTrue(reactionAdd.isReactionEvent, "should be identified as a reaction event")
177 XCTAssertEqual(reactionAdd.type, "reaction", "type should be 'reaction'")
178 XCTAssertEqual(reactionAdd.targetSid, 12345, "targetSid should be the message id")
179 XCTAssertEqual(reactionAdd.targetUid, "user-eve-1", "targetUid should be the reacted-to message's author")
180 XCTAssertEqual(reactionAdd.emoji, "πŸ‘", "emoji should match")
181 XCTAssertTrue(reactionAdd.on ?? false, "on should be true for adding")
182 XCTAssertEqual(reactionAdd.uid, "user-alice-1", "uid should be the reactor's id")
183 XCTAssertEqual(reactionAdd.name, "Alice", "name should be the reactor's name")
184
185 // The push-side contract (sw.js + the NSE): only the reacted-to message's
186 // author is shown a reaction notification, decided by comparing targetUid
187 // to the local user after decryption.
188 XCTAssertEqual(reactionAdd.targetUid, "user-eve-1")
189 XCTAssertNotEqual(reactionAdd.targetUid, reactionAdd.uid, "reactor and author differ in this vector")
190
191 // The sidebar-side contract: targetText is a snippet of the reacted-to
192 // message, stamped by the reacting client so the chat list can quote it
193 // from last_blob alone. The fold ignores it (see expected_fold_event).
194 XCTAssertEqual(reactionAdd.targetText, "buy milk", "targetText carries the reacted-to snippet")
195
196 let reactionRemove = MessagePayload(
197 type: "reaction",
198 uid: "user-bob-1",
199 name: "Bob",
200 text: nil,
201 ts: 1700000015000,
202 action: nil,
203 attachments: nil,
204 replyTo: nil,
205 targetSid: 12346,
206 targetUid: "user-eve-1",
207 emoji: "❀️",
208 on: false,
209 targetText: "shipped it πŸŽ‰"
210 )
211
212 XCTAssertTrue(reactionRemove.isReactionEvent, "should be identified as a reaction event")
213 XCTAssertFalse(reactionRemove.on ?? true, "on should be false for removing")
214 XCTAssertEqual(reactionRemove.targetUid, "user-eve-1", "targetUid rides on removals too")
215 XCTAssertEqual(reactionRemove.targetText, "shipped it πŸŽ‰", "so does targetText β€” an un-react still speaks in the sidebar")
216 }
217
218 func testReactionFold() {
219 // The native fold must match chat.js's "chat.reaction" handler: events
220 // accumulate keyed by (emoji, uid), the tally derives from the on=true
221 // set, and replaying an event is idempotent (a history re-pull can never
222 // double-count).
223 var m = ChatFeedModel.Message(id: 12345, name: "Eve", text: "hello",
224 mine: false, at: 1700000000, edited: false)
225 let add = MessagePayload(type: "reaction", uid: "user-alice-1", name: "Alice",
226 ts: 1700000010000, targetSid: 12345,
227 targetUid: "user-eve-1", emoji: "πŸ‘", on: true)
228
229 XCTAssertTrue(ChatFeedModel.foldReaction(add, into: &m, me: "user-eve-1"))
230 XCTAssertEqual(m.reactions, [Reaction(emoji: "πŸ‘", count: 1, mine: false)],
231 "add derives a count of 1, not mine")
232
233 XCTAssertTrue(ChatFeedModel.foldReaction(add, into: &m, me: "user-eve-1"))
234 XCTAssertEqual(m.reactions, [Reaction(emoji: "πŸ‘", count: 1, mine: false)],
235 "re-folding the same event is a no-op on the tally")
236
237 var mine = add
238 mine.uid = "user-eve-1"
239 XCTAssertTrue(ChatFeedModel.foldReaction(mine, into: &m, me: "user-eve-1"))
240 XCTAssertEqual(m.reactions, [Reaction(emoji: "πŸ‘", count: 2, mine: true)],
241 "my own uid among the reactors sets the mine flag")
242
243 var remove = add
244 remove.on = false
245 XCTAssertTrue(ChatFeedModel.foldReaction(remove, into: &m, me: "user-eve-1"))
246 XCTAssertEqual(m.reactions, [Reaction(emoji: "πŸ‘", count: 1, mine: true)],
247 "a removal folds that reactor out of the tally")
248
249 var elsewhere = add
250 elsewhere.targetSid = 99999
251 XCTAssertFalse(ChatFeedModel.foldReaction(elsewhere, into: &m, me: "user-eve-1"),
252 "an event for another message never touches this one")
253 }
254
255 func testOptimisticSendLifecycle() {
256 // The optimistic-send contract (web parity β€” chat.js): a message folds as a
257 // PENDING copy the instant it's sent, then its own echo (same _cid, now a
258 // real server id) UPGRADES that copy in place rather than duplicating it.
259 let cid = "cid-abc"
260 var list: [ChatFeedModel.Message] = []
261
262 // 1. Optimistic copy: a synthetic pending id (sorts last), pending, keyed by cid.
263 var pending = ChatFeedModel.Message(id: (1 << 62), name: "Ada", text: "hi there",
264 mine: true, at: 1700000000, edited: false)
265 pending.cid = cid
266 pending.pending = true
267 ChatFeedModel.foldOptimistic(pending, pairCid: cid, pending: true, into: &list)
268 XCTAssertEqual(list.count, 1, "the pending copy shows immediately")
269 XCTAssertTrue(list[0].pending, "flagged pending")
270 XCTAssertEqual(list[0].cid, cid, "keyed by the client id")
271
272 // 2. Our own echo confirms it: adopt the server id, drop pending, no duplicate.
273 let echo = ChatFeedModel.Message(id: 42, name: "Ada", text: "hi there",
274 mine: true, at: 1700000005, edited: false)
275 ChatFeedModel.foldOptimistic(echo, pairCid: cid, pending: false, into: &list)
276 XCTAssertEqual(list.count, 1, "the echo upgrades in place β€” no duplicate")
277 XCTAssertEqual(list[0].id, 42, "adopts the real server id")
278 XCTAssertFalse(list[0].pending, "no longer pending")
279 XCTAssertNil(list[0].cid, "client id cleared on confirm")
280 XCTAssertEqual(list[0].at, 1700000005, "adopts the server time")
281
282 // 3. A duplicate echo (reconnect / history re-pull) is idempotent.
283 ChatFeedModel.foldOptimistic(echo, pairCid: cid, pending: false, into: &list)
284 XCTAssertEqual(list.count, 1, "re-folding the confirmed message changes nothing")
285 }
286
287 func testOptimisticPendingSortsLast() {
288 // A pending copy sits below every confirmed message until its echo slots it
289 // into server order (mirrors the web bySidPendingLast comparator).
290 var list: [ChatFeedModel.Message] = []
291 let first = ChatFeedModel.Message(id: 100, name: "Bo", text: "first",
292 mine: false, at: 10, edited: false)
293 ChatFeedModel.foldOptimistic(first, pairCid: nil, pending: false, into: &list)
294 var mine = ChatFeedModel.Message(id: (1 << 62), name: "Ada", text: "mine",
295 mine: true, at: 20, edited: false)
296 mine.cid = "z"; mine.pending = true
297 ChatFeedModel.foldOptimistic(mine, pairCid: "z", pending: true, into: &list)
298 XCTAssertEqual(list.map { $0.text }, ["first", "mine"], "pending sits at the bottom")
299
300 // A higher-id message arriving before our echo still lands before the pending copy.
301 let later = ChatFeedModel.Message(id: 200, name: "Cy", text: "later",
302 mine: false, at: 30, edited: false)
303 ChatFeedModel.foldOptimistic(later, pairCid: nil, pending: false, into: &list)
304 XCTAssertEqual(list.map { $0.text }, ["first", "later", "mine"], "pending stays last until confirmed")
305
306 // On confirm it slots into server order.
307 let confirm = ChatFeedModel.Message(id: 150, name: "Ada", text: "mine",
308 mine: true, at: 20, edited: false)
309 ChatFeedModel.foldOptimistic(confirm, pairCid: "z", pending: false, into: &list)
310 XCTAssertEqual(list.map { $0.text }, ["first", "mine", "later"], "confirmed message sorts by server id")
311 }
312
313 func testOptimisticCidCarriedAndIgnored() {
314 // A MessagePayload carrying _cid decodes cleanly and never leaks the id into
315 // content β€” native must carry-and-ignore it (Codable drops unknown-to-old keys).
316 let json = #"{"text":"hello","name":"Bo","ts":5,"_cid":"unmatched"}"#.data(using: .utf8)!
317 let p = try? JSONDecoder().decode(MessagePayload.self, from: json)
318 XCTAssertEqual(p?.text, "hello", "_cid never leaks into the text")
319 XCTAssertEqual(p?._cid, "unmatched", "the client id round-trips")
320 }
321
322 func testEditedMessage() {
323 // Edited messages: the payload is the new text, edited_at is server-side.
324 let original = MessagePayload(
325 type: nil,
326 uid: "user-eve-1",
327 name: "Eve",
328 text: "typo here",
329 ts: 1700000005000,
330 action: nil,
331 attachments: nil,
332 replyTo: nil
333 )
334
335 let edited = MessagePayload(
336 type: nil,
337 uid: "user-eve-1",
338 name: "Eve",
339 text: "no typo here",
340 ts: 1700000005000,
341 action: nil,
342 attachments: nil,
343 replyTo: nil
344 )
345
346 XCTAssertNotEqual(original.text, edited.text, "text should differ")
347 XCTAssertEqual(original.ts, edited.ts, "sender clock should stay the same")
348 }
349
350 func testLegacyMessageNoUid() {
351 // Older messages before uid was added β€” both web and native fall back to name.
352 let payload = MessagePayload(
353 type: nil,
354 uid: nil,
355 name: "Frank",
356 text: "This is old",
357 ts: 1699000000000,
358 action: nil,
359 attachments: nil,
360 replyTo: nil
361 )
362
363 XCTAssertNil(payload.uid, "uid should be nil (legacy)")
364 XCTAssertEqual(payload.name, "Frank", "name should be set for attribution")
365 XCTAssertTrue(payload.isRenderableChat, "legacy messages are renderable")
366 }
367
368 // JSONDecoder round-trip: ensure payloads decode from JSON (from server blobs).
369 func testDecodingFromJSON() throws {
370 let json = """
371 {
372 "name": "Alice",
373 "text": "Hello",
374 "ts": 1700000000000,
375 "uid": "user-alice-1",
376 "attachments": [
377 {
378 "id": "blob-1",
379 "name": "photo.jpg",
380 "type": "image/jpeg",
381 "w": 1920,
382 "h": 1080
383 }
384 ]
385 }
386 """
387
388 let data = json.data(using: .utf8)!
389 let payload = try JSONDecoder().decode(MessagePayload.self, from: data)
390
391 XCTAssertEqual(payload.name, "Alice")
392 XCTAssertEqual(payload.text, "Hello")
393 XCTAssertEqual(payload.attachments?.count, 1)
394 XCTAssertEqual(payload.attachments?[0].id, "blob-1")
395 }
396
397 func testDecodingReactionFromJSON() throws {
398 let json = """
399 {
400 "emoji": "πŸ‘",
401 "count": 1,
402 "mine": false
403 }
404 """
405
406 let data = json.data(using: .utf8)!
407 let reaction = try JSONDecoder().decode(Reaction.self, from: data)
408
409 XCTAssertEqual(reaction.emoji, "πŸ‘")
410 XCTAssertEqual(reaction.count, 1)
411 XCTAssertFalse(reaction.isMine)
412 }
413
414 // The admin_warning_message vector (test/event-vectors/chat.json, #578):
415 // an ordinary DM message whose payload carries warn:true. Same bubble,
416 // distinct "notice from an admin" framing; the flag rides inside the
417 // ciphertext, so the server never knows a warning from a hello.
418 func testAdminWarningMessage() throws {
419 let json = """
420 {
421 "name": "Root",
422 "text": "A report was filed about your behaviour here. Please keep this space welcoming.",
423 "ts": 1700000300000,
424 "uid": "user-admin-1",
425 "warn": true
426 }
427 """
428
429 let data = json.data(using: .utf8)!
430 let payload = try JSONDecoder().decode(MessagePayload.self, from: data)
431
432 XCTAssertEqual(payload.name, "Root")
433 XCTAssertEqual(payload.text,
434 "A report was filed about your behaviour here. Please keep this space welcoming.")
435 XCTAssertEqual(payload.uid, "user-admin-1")
436 XCTAssertTrue(payload.warn ?? false, "warn flag should decode true")
437 XCTAssertTrue(payload.isRenderableChat, "a warning renders as an ordinary chat message")
438
439 // An ordinary message decodes warn as nil β€” absent means not a warning.
440 let plain = try JSONDecoder().decode(
441 MessagePayload.self, from: #"{"name":"Alice","text":"hello"}"#.data(using: .utf8)!)
442 XCTAssertNil(plain.warn, "absent warn should be nil, never false-positive")
443 }
444
445 func testDecodingWithMissingOptionalFields() throws {
446 // The server may omit optional fields; decoder should handle gracefully.
447 let json = """
448 {
449 "name": "Bob",
450 "text": "Simple message"
451 }
452 """
453
454 let data = json.data(using: .utf8)!
455 let payload = try JSONDecoder().decode(MessagePayload.self, from: data)
456
457 XCTAssertEqual(payload.name, "Bob")
458 XCTAssertEqual(payload.text, "Simple message")
459 XCTAssertNil(payload.uid, "missing uid should be nil")
460 XCTAssertNil(payload.ts, "missing ts should be nil")
461 XCTAssertNil(payload.attachments, "missing attachments should be nil")
462 }
463}
464