// // 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)"), displayName: "peer \(suffix)", noisePublicKeyHex: suffix, nostrPublicKey: nil )) } @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) } }