// // ConversationStoreTests.swift // bitchatTests // // Tests for the new single-source-of-truth ConversationStore // (docs/CONVERSATION-STORE-DESIGN.md): intent API, ordered insertion, // dedup, caps, delivery-status rules, migration, unread state, change // emission, and per-conversation publish isolation. // // This is free and unencumbered software released into the public domain. // For more information, see // import BitFoundation import Combine import Foundation import Testing @testable import bitchat @MainActor private func makeMessage( id: String, timestamp: TimeInterval, content: String? = nil, isPrivate: Bool = false, deliveryStatus: DeliveryStatus? = nil ) -> BitchatMessage { BitchatMessage( id: id, sender: "alice", content: content ?? "message \(id)", timestamp: Date(timeIntervalSince1970: timestamp), isRelay: false, isPrivate: isPrivate, recipientNickname: isPrivate ? "bob" : nil, senderPeerID: PeerID(str: "peer-a"), deliveryStatus: deliveryStatus ) } private func makeDirectConversationID(_ suffix: String) -> ConversationID { .direct(PeerHandle( id: "noise:\(suffix)", routingPeerID: PeerID(str: "peer-\(suffix)") )) } @Suite("ConversationStore") struct ConversationStoreTests { // MARK: - Append, dedup, ordering @Test("append dedups by message ID and reports duplicates") @MainActor func appendDedupsByMessageID() { let store = ConversationStore() var received: [ConversationChange] = [] let cancellable = store.changes.sink { received.append($0) } defer { cancellable.cancel() } #expect(store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh)) #expect(!store.append(makeMessage(id: "m1", timestamp: 2, content: "dup"), to: .mesh)) let conversation = store.conversation(for: .mesh) #expect(conversation.messages.count == 1) #expect(conversation.messages.first?.content == "message m1") #expect(conversation.containsMessage(withID: "m1")) #expect(received.count == 1) guard case .appended(.mesh, let message) = received.first else { Issue.record("expected a single .appended change, got \(received)") return } #expect(message.id == "m1") } @Test("out-of-order appends are inserted in timestamp order") @MainActor func outOfOrderInsertKeepsTimestampOrder() { let store = ConversationStore() store.append(makeMessage(id: "m1", timestamp: 10), to: .mesh) store.append(makeMessage(id: "m3", timestamp: 30), to: .mesh) store.append(makeMessage(id: "m2", timestamp: 20), to: .mesh) store.append(makeMessage(id: "m0", timestamp: 5), to: .mesh) let conversation = store.conversation(for: .mesh) #expect(conversation.messages.map(\.id) == ["m0", "m1", "m2", "m3"]) // The ID index must survive middle inserts: lookups by ID still // resolve to the right message. #expect(conversation.message(withID: "m2")?.timestamp == Date(timeIntervalSince1970: 20)) #expect(conversation.message(withID: "m0")?.timestamp == Date(timeIntervalSince1970: 5)) } @Test("equal timestamps preserve arrival order") @MainActor func equalTimestampsPreserveArrivalOrder() { let store = ConversationStore() store.append(makeMessage(id: "first", timestamp: 10), to: .mesh) store.append(makeMessage(id: "second", timestamp: 10), to: .mesh) // A late message with an equal timestamp lands after existing peers. store.append(makeMessage(id: "late-tail", timestamp: 20), to: .mesh) store.append(makeMessage(id: "third", timestamp: 10), to: .mesh) let conversation = store.conversation(for: .mesh) #expect(conversation.messages.map(\.id) == ["first", "second", "third", "late-tail"]) } // MARK: - Caps @Test("cap trims oldest messages and keeps the ID index valid") @MainActor func capTrimsOldestAndKeepsIndexValid() { let store = ConversationStore() let conversation = store.conversation(for: .mesh) let cap = conversation.cap #expect(cap == TransportConfig.meshTimelineCap) let overflow = 3 for i in 0..<(cap + overflow) { store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh) } #expect(conversation.messages.count == cap) #expect(conversation.messages.first?.id == "m\(overflow)") #expect(conversation.messages.last?.id == "m\(cap + overflow - 1)") // Trimmed messages left the index entirely… for i in 0..() conversationA.objectWillChange .sink { aWillChangeCount += 1 } .store(in: &cancellables) conversationB.objectWillChange .sink { bWillChangeCount += 1 } .store(in: &cancellables) store.append(makeMessage(id: "m1", timestamp: 1), to: a) store.append(makeMessage(id: "m2", timestamp: 2), to: a) store.setDeliveryStatus(.sent, forMessageID: "m1", in: a) store.markUnread(a) #expect(aWillChangeCount >= 4) #expect(bWillChangeCount == 0) store.append(makeMessage(id: "m3", timestamp: 3), to: b) #expect(bWillChangeCount > 0) } // MARK: - Public timelines (mesh/geohash, ex-PublicTimelineStore behavior) @Test("geohash conversations are separated by geohash and from mesh") @MainActor func geohashConversationSeparation() { let store = ConversationStore() store.append(makeMessage(id: "mesh-1", timestamp: 1), to: .mesh) store.append(makeMessage(id: "geo-a-1", timestamp: 2), to: .geohash("u4pruyd")) store.append(makeMessage(id: "geo-b-1", timestamp: 3), to: .geohash("9q8yy")) #expect(store.conversation(for: .mesh).messages.map(\.id) == ["mesh-1"]) #expect(store.conversation(for: .geohash("u4pruyd")).messages.map(\.id) == ["geo-a-1"]) #expect(store.conversation(for: .geohash("9q8yy")).messages.map(\.id) == ["geo-b-1"]) } @Test("geohash append dedups by ID and reports duplicates") @MainActor func geohashAppendIfAbsentContract() { let store = ConversationStore() let message = makeMessage(id: "geo-1", timestamp: 1) #expect(store.append(message, to: .geohash("u4pruyd"))) #expect(!store.append(message, to: .geohash("u4pruyd"))) // The same ID is still fresh in a different geohash. #expect(store.append(message, to: .geohash("9q8yy"))) } @Test("removePublicMessage searches mesh and geohash conversations only") @MainActor func removePublicMessageSearchesPublicConversations() { let store = ConversationStore() let direct = makeDirectConversationID("aa") store.append(makeMessage(id: "mesh-1", timestamp: 1), to: .mesh) store.append(makeMessage(id: "geo-1", timestamp: 2), to: .geohash("u4pruyd")) store.append(makeMessage(id: "dm-1", timestamp: 3, isPrivate: true), to: direct) #expect(store.removePublicMessage(withID: "geo-1")?.id == "geo-1") #expect(store.conversation(for: .geohash("u4pruyd")).messages.isEmpty) #expect(store.removePublicMessage(withID: "mesh-1")?.id == "mesh-1") #expect(store.conversation(for: .mesh).messages.isEmpty) // Direct conversations are never touched. #expect(store.removePublicMessage(withID: "dm-1") == nil) #expect(store.conversation(for: direct).messages.map(\.id) == ["dm-1"]) } @Test("removeMessages(from:where:) purges matches and emits per removal") @MainActor func removeMessagesByPredicate() { let store = ConversationStore() let id = ConversationID.geohash("u4pruyd") store.append(makeMessage(id: "keep-1", timestamp: 1), to: id) store.append(makeMessage(id: "drop-1", timestamp: 2, content: "purge me"), to: id) store.append(makeMessage(id: "drop-2", timestamp: 3, content: "purge me"), to: id) store.append(makeMessage(id: "keep-2", timestamp: 4), to: id) var removedIDs: [String] = [] var cancellables = Set() store.changes .sink { change in if case .messageRemoved(_, let messageID) = change { removedIDs.append(messageID) } } .store(in: &cancellables) store.removeMessages(from: id, where: { $0.content == "purge me" }) #expect(store.conversation(for: id).messages.map(\.id) == ["keep-1", "keep-2"]) #expect(removedIDs == ["drop-1", "drop-2"]) // The ID index survives the purge: dedup and removal still work. #expect(!store.append(makeMessage(id: "keep-2", timestamp: 4), to: id)) #expect(store.removeMessage(withID: "keep-1", from: id) != nil) #expect(store.conversation(for: id).messages.map(\.id) == ["keep-2"]) } @Test("trimmed public message IDs can return after falling off the cap") @MainActor func trimmedMessageIDsCanReturn() { let store = ConversationStore() let id = ConversationID.geohash("u4pruyd") let conversation = store.conversation(for: id) let first = makeMessage(id: "one", timestamp: 1) store.append(first, to: id) for index in 0..() var publishedIDs: [ConversationID] = [] for id in [stable, ephemeral] { store.conversation(for: id).objectWillChange .sink { publishedIDs.append(id) } .store(in: &cancellables) } var statusChangedIDs: [ConversationID] = [] store.changes .sink { change in if case .statusChanged(let id, "dm-2", _) = change { statusChangedIDs.append(id) } } .store(in: &cancellables) let read = DeliveryStatus.read(by: "bob", at: Date()) #expect(store.setDeliveryStatus(read, forMessageID: "dm-2")) // The shared instance is mutated once, but a view observing EITHER // conversation must re-render, and both emit a change event. #expect(Set(publishedIDs) == Set([stable, ephemeral])) #expect(Set(statusChangedIDs) == Set([stable, ephemeral])) // A duplicate ack applies nowhere and must publish nothing. publishedIDs.removeAll() statusChangedIDs.removeAll() #expect(!store.setDeliveryStatus(read, forMessageID: "dm-2")) #expect(publishedIDs.isEmpty) #expect(statusChangedIDs.isEmpty) } }