// // 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) } // MARK: - Invariant audit (field observability) /// A store exercised through every intent family: public + geohash + /// mirrored direct conversations, out-of-order and equal-timestamp /// appends, upserts, delivery updates, removal, migration, unread, and /// selection. Used as the healthy baseline for audit tests. @MainActor private static func makeExercisedStore() -> ConversationStore { let store = ConversationStore() let stable = makeDirectConversationID("stable") let ephemeral = makeDirectConversationID("ephemeral") 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) // out of order store.append(makeMessage(id: "m2b", timestamp: 20), to: .mesh) // equal timestamp store.append(makeMessage(id: "g1", timestamp: 5), to: .geohash("u4pruyd")) store.upsertByID(makeMessage(id: "g1", timestamp: 5, content: "edited"), in: .geohash("u4pruyd")) // Mirrored private copy (shared instance) across two direct chats. let mirrored = makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent) store.upsertByID(mirrored, in: stable) store.upsertByID(mirrored, in: ephemeral) store.setDeliveryStatus(.delivered(to: "bob", at: Date(timeIntervalSince1970: 2)), forMessageID: "dm-1") store.append(makeMessage(id: "dm-2", timestamp: 3, isPrivate: true), to: ephemeral) store.migrateConversation(from: ephemeral, to: stable) store.append(makeMessage(id: "dm-gone", timestamp: 4, isPrivate: true), to: stable) store.removeMessage(withID: "dm-gone", from: stable) store.markUnread(stable) store.setActiveChannel(.mesh) return store } @Test("audit reports no violations for a healthy, well-exercised store") @MainActor func auditHealthyStoreIsClean() { let store = Self.makeExercisedStore() #expect(store.auditInvariants().isEmpty) // Selection through both axes stays healthy. store.setSelectedPrivatePeer(PeerID(str: "peer-stable")) #expect(store.auditInvariants().isEmpty) store.setSelectedPrivatePeer(nil) #expect(store.auditInvariants().isEmpty) } @Test("audit flags index entries pointing at the wrong position") @MainActor func auditFlagsCorruptIndexEntries() { let store = Self.makeExercisedStore() store.conversation(for: .mesh)._testCorruptIndexEntries() let violations = store.auditInvariants() #expect(!violations.isEmpty) #expect(violations.contains { $0.contains("mesh") && $0.contains("indexed at") }) } @Test("audit flags a message missing from the per-conversation index") @MainActor func auditFlagsMissingIndexEntry() { let store = Self.makeExercisedStore() store.conversation(for: .mesh)._testRemoveIndexEntry(forMessageID: "m1") let violations = store.auditInvariants() #expect(violations.contains { $0.contains("missing from index") }) #expect(violations.contains { $0.contains("index has") }) // count mismatch } @Test("audit flags timestamp-order violations") @MainActor func auditFlagsOrderingViolation() { let store = Self.makeExercisedStore() store.conversation(for: .mesh)._testCorruptOrderingPreservingIndex() let violations = store.auditInvariants() #expect(violations.contains { $0.contains("timestamp order violated") }) // The hook keeps the index consistent: no index violations leak in. #expect(!violations.contains { $0.contains("indexed at") || $0.contains("missing from index") }) } @Test("audit flags map memberships the conversation does not hold") @MainActor func auditFlagsPhantomMapMembership() { let store = Self.makeExercisedStore() store._testRegisterPhantomMessageID("ghost", in: .mesh) let violations = store.auditInvariants() #expect(violations.contains { $0.contains("not present in claimed conversation") }) #expect(violations.contains { $0.contains("memberships but conversations hold") }) } @Test("audit flags map memberships claiming unknown conversations") @MainActor func auditFlagsUnknownConversationMembership() { let store = Self.makeExercisedStore() store._testRegisterPhantomMessageID("ghost", in: makeDirectConversationID("nope")) let violations = store.auditInvariants() #expect(violations.contains { $0.contains("claims unknown conversation") }) } @Test("audit flags conversation messages missing from the map") @MainActor func auditFlagsMissingMapMembership() { let store = Self.makeExercisedStore() store._testUnregisterMessageID("m1", from: .mesh) let violations = store.auditInvariants() #expect(violations.contains { $0.contains("memberships but conversations hold") }) } @Test("audit flags a conversation exceeding its cap") @MainActor func auditFlagsCapViolation() { let store = ConversationStore() let conversation = store.conversation(for: .mesh) for index in 0..