eleven / ocho Public

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

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 // ---- thread-only replies (replyTo.thread) β€” the message_in_thread vector ----
465 // A reply made IN a thread (the docked pane / thread view composer, "Reply
466 // in thread") carries replyTo.thread:true inside the ciphertext. Native
467 // decodes it as ReplyRef.thread, and the main feed's item list
468 // (ChatFeedModel.mainFeedItems) leaves it out β€” Slack's model: the feed
469 // shows the root's "N replies" tally, the reply lives in the thread view.
470 // Read straight from test/event-vectors/chat.json, strictly: the keys ARE
471 // the contract, so a missing vector or key fails here, never skips.
472
473 func testThreadReplyVectorDecodesFlagAndHidesFromFeed() throws {
474 let v = try ChatVectors.vector("message_in_thread")
475 let payload = try ChatVectors.payload(v)
476
477 // expected_native_fields.replyTo β€” every field, thread included.
478 let native = try XCTUnwrap(v["expected_native_fields"] as? [String: Any],
479 "message_in_thread must carry expected_native_fields")
480 XCTAssertEqual(payload.name, native["name"] as? String)
481 XCTAssertEqual(payload.text, native["text"] as? String)
482 XCTAssertEqual(payload.uid, native["uid"] as? String)
483 XCTAssertEqual(payload.isRenderableChat, native["isRenderableChat"] as? Bool)
484 let ref = try XCTUnwrap(native["replyTo"] as? [String: Any],
485 "expected_native_fields must carry replyTo")
486 XCTAssertEqual(payload.replyTo?.id, (ref["id"] as? NSNumber)?.int64Value)
487 XCTAssertEqual(payload.replyTo?.name, ref["name"] as? String)
488 XCTAssertEqual(payload.replyTo?.snippet, ref["snippet"] as? String)
489 let flag = try XCTUnwrap(ref["thread"] as? Bool,
490 "expected_native_fields.replyTo.thread is the contract")
491 XCTAssertTrue(flag, "the vector is the flagged case")
492 XCTAssertEqual(payload.replyTo?.thread, flag, "ReplyRef.thread decodes the flag")
493
494 // expected_main_feed_visible, through the pure filter, with the ROOT
495 // loaded (the tally's home β€” a root the transcript shows): the folded
496 // reply is absent from the main feed's list. Nothing about the fold
497 // changes β€” it is still a renderable chat message.
498 let visible = try XCTUnwrap(v["expected_main_feed_visible"] as? Bool,
499 "message_in_thread must carry expected_main_feed_visible")
500 let msg = try ChatVectors.message(v)
501 let rootID = try XCTUnwrap(payload.replyTo?.id)
502 let root = ChatFeedModel.Message(id: rootID, name: payload.replyTo?.name ?? "",
503 text: payload.replyTo?.snippet ?? "", mine: false,
504 at: msg.at - 1, edited: false)
505 XCTAssertTrue(payload.isRenderableChat)
506 XCTAssertTrue(ChatFeedModel.isThreadOnly(msg))
507 XCTAssertEqual(ChatFeedModel.mainFeedItems([root, msg]).contains { $0.id == msg.id }, visible,
508 "mainFeedItems must agree with expected_main_feed_visible")
509 XCTAssertFalse(visible, "the vector pins the hidden case")
510 // Root NOT loaded (older than the loaded page): the reply degrades to an
511 // inline bubble rather than rendering nowhere β€” thread_reply_root_unloaded
512 // pins the sequence form of this in ReplyThreadVectorTests.
513 XCTAssertEqual(ChatFeedModel.mainFeedItems([msg]).map(\.id), [msg.id],
514 "a thread reply with no loaded root stays in the feed")
515 }
516
517 func testThreadConvertPayloadDecodesAndIsNotChat() throws {
518 // The wire shape of "Convert to thread", straight off the vector file: a
519 // typed payload carrying {root, ids} and no text. It is neither renderable
520 // chat nor an app event, which is exactly what makes it SAFE for an older
521 // client β€” one that predates the type drops it and keeps showing the chain
522 // inline (the degradation expected_main_feed_unconverted pins).
523 let v = try ChatVectors.vector("convert_inline_chain_to_thread")
524 let steps = try XCTUnwrap(v["message_sequence"] as? [[String: Any]])
525 let step = try XCTUnwrap(steps.first { ($0["decrypted_payload"] as? [String: Any])?["type"] as? String == "threadconvert" },
526 "the vector must carry a threadconvert step")
527 let p = try ChatVectors.payload(step)
528 XCTAssertTrue(p.isThreadConvertEvent, "type: threadconvert")
529 XCTAssertFalse(p.isRenderableChat, "never a bubble β€” an older client drops it")
530 XCTAssertFalse(p.isReactionEvent)
531 XCTAssertNil(p.text, "a conversion says nothing; it moves what was already said")
532 XCTAssertEqual(p.root, 400)
533 XCTAssertEqual(p.ids, [401, 402])
534 // Round-trips: the payload we'd SEND encodes to the same fields.
535 var sent = MessagePayload(type: "threadconvert", uid: p.uid, name: p.name, ts: p.ts)
536 sent.root = p.root
537 sent.ids = p.ids
538 let data = try JSONEncoder().encode(sent)
539 let back = try JSONDecoder().decode(MessagePayload.self, from: data)
540 XCTAssertEqual(back.root, 400)
541 XCTAssertEqual(back.ids, [401, 402])
542 XCTAssertTrue(back.isThreadConvertEvent)
543 }
544
545 func testInlineReplyVectorStaysInFeed() throws {
546 // The pre-thread wire shape β€” replyTo with no flag β€” is an inline quote
547 // reply: it decodes thread == nil (never a false positive) and stays a
548 // main-feed bubble.
549 let v = try ChatVectors.vector("message_with_reply")
550 let visible = try XCTUnwrap(v["expected_main_feed_visible"] as? Bool,
551 "message_with_reply must carry expected_main_feed_visible")
552 let payload = try ChatVectors.payload(v)
553 XCTAssertNotNil(payload.replyTo)
554 XCTAssertNil(payload.replyTo?.thread, "no flag on the wire decodes nil")
555 let msg = try ChatVectors.message(v)
556 XCTAssertFalse(ChatFeedModel.isThreadOnly(msg))
557 // Evaluated the way every single-message vector is: with the quoted
558 // root seeded into the loaded window β€” and, unflagged, just as visible
559 // without it.
560 let rootID = try XCTUnwrap(payload.replyTo?.id)
561 let root = ChatFeedModel.Message(id: rootID, name: payload.replyTo?.name ?? "",
562 text: payload.replyTo?.snippet ?? "", mine: false,
563 at: msg.at - 1, edited: false)
564 XCTAssertEqual(ChatFeedModel.mainFeedItems([root, msg]).contains { $0.id == msg.id }, visible)
565 XCTAssertEqual(ChatFeedModel.mainFeedItems([msg]).contains { $0.id == msg.id }, visible)
566 XCTAssertTrue(visible, "the vector pins the visible case")
567 }
568
569 func testThreadFlagRoundTripsByteIdentically() throws {
570 // History without the key re-encodes without it (an edit re-encrypts the
571 // raw payload β€” the shape old clients wrote must come back unchanged);
572 // a flagged ref keeps its true through a decode/encode/decode cycle.
573 let plain = try JSONDecoder().decode(
574 ReplyRef.self, from: #"{"id":1,"name":"A","snippet":"s"}"#.data(using: .utf8)!)
575 XCTAssertNil(plain.thread)
576 let plainOut = String(decoding: try JSONEncoder().encode(plain), as: UTF8.self)
577 XCTAssertFalse(plainOut.contains("thread"), "nil never appears on the wire: \(plainOut)")
578
579 let flagged = plain.inThread()
580 XCTAssertEqual(flagged.thread, true)
581 XCTAssertEqual(flagged.id, plain.id)
582 XCTAssertEqual(flagged.snippet, plain.snippet)
583 let flaggedOut = String(decoding: try JSONEncoder().encode(flagged), as: UTF8.self)
584 XCTAssertTrue(flaggedOut.contains(#""thread":true"#), "the flag rides inside replyTo: \(flaggedOut)")
585 XCTAssertEqual(try JSONDecoder().decode(ReplyRef.self, from: Data(flaggedOut.utf8)).thread, true)
586
587 // The whole payload survives an edit's re-encode (chat.js's {…raw, text}).
588 let v = try ChatVectors.vector("message_in_thread")
589 var payload = try ChatVectors.payload(v)
590 payload.text = "In the thread (edited)"
591 let again = try JSONDecoder().decode(MessagePayload.self, from: try JSONEncoder().encode(payload))
592 XCTAssertEqual(again.replyTo?.thread, true, "an edit keeps the reply in its thread")
593 XCTAssertEqual(again.replyTo?.id, payload.replyTo?.id)
594 }
595}
596