// // 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)") )) } /// Deliberately simple O(n) model used to differentially test the store's /// optimized logical-index bookkeeping. It models observable behavior only; /// it has no offset or ID index and therefore cannot reproduce the same bug. private struct ReferenceConversationTimeline { struct Message: Equatable { let id: String let timestamp: Date let content: String var deliveryStatus: DeliveryStatus? init(_ message: BitchatMessage) { id = message.id timestamp = message.timestamp content = message.content deliveryStatus = message.deliveryStatus } } struct AppendResult { let inserted: Bool let trimmedCount: Int } let cap: Int private(set) var messages: [Message] = [] func contains(_ id: String) -> Bool { messages.contains { $0.id == id } } mutating func append(_ message: BitchatMessage) -> AppendResult { guard !contains(message.id) else { return AppendResult(inserted: false, trimmedCount: 0) } let snapshot = Message(message) var low = 0 var high = messages.count while low < high { let mid = (low + high) / 2 if messages[mid].timestamp <= snapshot.timestamp { low = mid + 1 } else { high = mid } } messages.insert(snapshot, at: low) let overflow = max(0, messages.count - cap) if overflow > 0 { messages.removeFirst(overflow) } return AppendResult(inserted: true, trimmedCount: overflow) } mutating func upsert(_ message: BitchatMessage) -> Int { if let index = messages.firstIndex(where: { $0.id == message.id }) { messages[index] = Message(message) return 0 } return append(message).trimmedCount } mutating func applyDeliveryStatus(_ status: DeliveryStatus, to id: String) -> Bool { guard let index = messages.firstIndex(where: { $0.id == id }), messages[index].deliveryStatus != status else { return false } // The differential stream uses only unique `.delivered` values (or // an exact repeat), so no-downgrade policy is intentionally outside // this index-focused reference model. messages[index].deliveryStatus = status return true } mutating func remove(at index: Int) -> Message { messages.remove(at: index) } mutating func removeAll(where predicate: (Message) -> Bool) { messages.removeAll(where: predicate) } mutating func clear() { messages.removeAll() } } private struct ConversationStoreDifferentialRNG { private var state: UInt64 init(seed: UInt64) { state = seed } mutating func next() -> UInt64 { state &+= 0x9E37_79B9_7F4A_7C15 var value = state value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9 value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB return value ^ (value >> 31) } mutating func index(upperBound: Int) -> Int { precondition(upperBound > 0) return Int(next() % UInt64(upperBound)) } } @MainActor private func expectStore( _ store: ConversationStore, matches reference: ReferenceConversationTimeline, issuedIDs: [String], checkpoint: String ) { let conversation = store.conversation(for: .mesh) let actual = conversation.messages.map(ReferenceConversationTimeline.Message.init) #expect(actual == reference.messages, "timeline mismatch at \(checkpoint)") let lookupSnapshot = reference.messages.compactMap { expected in conversation.message(withID: expected.id).map(ReferenceConversationTimeline.Message.init) } #expect(lookupSnapshot == reference.messages, "ID lookup mismatch at \(checkpoint)") #expect( Set(conversation.messageIDs) == Set(reference.messages.map(\.id)), "per-conversation ID set mismatch at \(checkpoint)" ) if !reference.messages.isEmpty { for index in Set([0, reference.messages.count / 2, reference.messages.count - 1]) { let id = reference.messages[index].id #expect(store.conversationIDs(forMessageID: id) == [.mesh], "store ID map mismatch at \(checkpoint)") } } let activeIDs = Set(reference.messages.map(\.id)) var checkedStaleIDs = 0 for id in issuedIDs.reversed() where !activeIDs.contains(id) { #expect(conversation.message(withID: id) == nil, "stale conversation index entry at \(checkpoint)") #expect(store.conversationIDs(forMessageID: id).isEmpty, "stale store ID map entry at \(checkpoint)") checkedStaleIDs += 1 if checkedStaleIDs == 16 { break } } #expect(store.auditInvariants().isEmpty, "invariant audit failed at \(checkpoint)") } @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.. BitchatMessage { let number = nextID nextID += 1 let id = "diff-\(number)" issuedIDs.append(id) let resolvedTimestamp: TimeInterval if let timestamp { resolvedTimestamp = timestamp } else { resolvedTimestamp = nextTailTimestamp nextTailTimestamp += 1 } let dropMarker = number.isMultiple(of: 11) ? " [drop]" : "" return makeMessage( id: id, timestamp: resolvedTimestamp, content: "\(tag) \(number)\(dropMarker)" ) } @discardableResult func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult { let expected = reference.append(message) let actual = store.append(message, to: .mesh) #expect(actual == expected.inserted, "append result mismatch at \(checkpoint)") trimmedCount += expected.trimmedCount return expected } func refill(extra: Int, checkpoint: String) { let appendCount = max(0, cap - reference.messages.count) + extra for index in 0.. 1_200) #expect(tailAppendCount > 300) #expect(outOfOrderCount > 150) #expect(duplicateOrReuseCount > 75) #expect(headRemovalCount > 50) #expect(middleRemovalCount > 50) #expect(upsertCount > 75) #expect(deliveryUpdateCount > 75) #expect(filterCount == 2) #expect(clearCount == 1) } // MARK: - Upsert @Test("upsertByID replaces in place and appends when absent") @MainActor func upsertReplacesOrAppends() { let store = ConversationStore() var received: [ConversationChange] = [] let cancellable = store.changes.sink { received.append($0) } defer { cancellable.cancel() } store.upsertByID(makeMessage(id: "m1", timestamp: 10), in: .mesh) store.append(makeMessage(id: "m2", timestamp: 20), to: .mesh) store.upsertByID(makeMessage(id: "m1", timestamp: 10, content: "edited"), in: .mesh) let conversation = store.conversation(for: .mesh) #expect(conversation.messages.map(\.id) == ["m1", "m2"]) #expect(conversation.message(withID: "m1")?.content == "edited") #expect(received.count == 3) guard case .appended(.mesh, let first) = received[0], first.id == "m1", case .appended(.mesh, let second) = received[1], second.id == "m2", case .updated(.mesh, let updatedID) = received[2], updatedID == "m1" else { Issue.record("unexpected change sequence: \(received)") return } } // MARK: - Delivery status @Test("setDeliveryStatus never downgrades read and skips equal statuses") @MainActor func deliveryStatusNoDowngrade() { let store = ConversationStore() let id = makeDirectConversationID("aa") store.append(makeMessage(id: "m1", timestamp: 1, isPrivate: true, deliveryStatus: .sending), to: id) var statusChanges: [DeliveryStatus] = [] let cancellable = store.changes.sink { change in if case .statusChanged(_, _, let status) = change { statusChanges.append(status) } } defer { cancellable.cancel() } let conversation = store.conversation(for: id) let readStatus = DeliveryStatus.read(by: "bob", at: Date(timeIntervalSince1970: 100)) #expect(store.setDeliveryStatus(.sent, forMessageID: "m1", in: id)) // Equal status is a no-op. #expect(!store.setDeliveryStatus(.sent, forMessageID: "m1", in: id)) #expect(store.setDeliveryStatus(readStatus, forMessageID: "m1", in: id)) // Read beats delivered and sent: both downgrades are refused. #expect(!store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "m1", in: id)) #expect(!store.setDeliveryStatus(.sent, forMessageID: "m1", in: id)) #expect(conversation.message(withID: "m1")?.deliveryStatus == readStatus) // Unknown message or conversation: refused, nothing emitted. #expect(!store.setDeliveryStatus(.sent, forMessageID: "nope", in: id)) #expect(!store.setDeliveryStatus(.sent, forMessageID: "m1", in: .mesh)) #expect(statusChanges == [.sent, readStatus]) } // MARK: - Unread state @Test("markUnread and markRead keep the set and conversation flag consistent") @MainActor func unreadStateConsistency() { let store = ConversationStore() let id = makeDirectConversationID("bb") var unreadChanges: [(ConversationID, Bool)] = [] let cancellable = store.changes.sink { change in if case .unreadChanged(let conversationID, let isUnread) = change { unreadChanges.append((conversationID, isUnread)) } } defer { cancellable.cancel() } // markRead on a never-unread conversation is a no-op. store.markRead(id) #expect(unreadChanges.isEmpty) store.markUnread(id) #expect(store.unreadConversations == [id]) #expect(store.conversation(for: id).isUnread) // Idempotent: marking unread twice emits once. store.markUnread(id) store.markRead(id) #expect(store.unreadConversations.isEmpty) #expect(!store.conversation(for: id).isUnread) #expect(unreadChanges.count == 2) #expect(unreadChanges[0].0 == id && unreadChanges[0].1 == true) #expect(unreadChanges[1].0 == id && unreadChanges[1].1 == false) } // MARK: - Selection @Test("select creates the conversation and clears with nil") @MainActor func selectTracksConversation() { let store = ConversationStore() let id = makeDirectConversationID("cc") store.select(id) #expect(store.selectedConversationID == id) #expect(store.conversationsByID[id] != nil) #expect(store.conversationIDs == [id]) store.select(nil) #expect(store.selectedConversationID == nil) } // MARK: - Migration @Test("migrateConversation moves messages, dedups, and preserves order") @MainActor func migrationMovesAndDedups() { let store = ConversationStore() let ephemeral = makeDirectConversationID("old") let stable = makeDirectConversationID("new") store.append(makeMessage(id: "m1", timestamp: 10), to: ephemeral) store.append(makeMessage(id: "m3", timestamp: 30), to: ephemeral) store.append(makeMessage(id: "m2", timestamp: 20), to: stable) store.append(makeMessage(id: "m3", timestamp: 30, content: "already there"), to: stable) var received: [ConversationChange] = [] let cancellable = store.changes.sink { received.append($0) } defer { cancellable.cancel() } store.migrateConversation(from: ephemeral, to: stable) let destination = store.conversation(for: stable) #expect(destination.messages.map(\.id) == ["m1", "m2", "m3"]) // Existing copy wins the dedup. #expect(destination.message(withID: "m3")?.content == "already there") #expect(store.conversationsByID[ephemeral] == nil) #expect(!store.conversationIDs.contains(ephemeral)) #expect(received.count == 1) guard case .migrated(let from, let to) = received.first, from == ephemeral, to == stable else { Issue.record("expected a single .migrated change, got \(received)") return } } @Test("migrateConversation hands off unread state and selection") @MainActor func migrationMovesUnreadAndSelection() { let store = ConversationStore() let ephemeral = makeDirectConversationID("old") let stable = makeDirectConversationID("new") store.append(makeMessage(id: "m1", timestamp: 10), to: ephemeral) store.markUnread(ephemeral) store.select(ephemeral) store.migrateConversation(from: ephemeral, to: stable) #expect(store.unreadConversations == [stable]) #expect(store.conversation(for: stable).isUnread) #expect(store.selectedConversationID == stable) // Migrating from a missing source or onto itself is a no-op. store.migrateConversation(from: ephemeral, to: stable) store.migrateConversation(from: stable, to: stable) #expect(store.conversation(for: stable).messages.map(\.id) == ["m1"]) } // MARK: - Clear / remove @Test("clear empties the timeline but keeps the conversation") @MainActor func clearKeepsConversation() { let store = ConversationStore() let id = makeDirectConversationID("dd") store.append(makeMessage(id: "m1", timestamp: 1), to: id) store.markUnread(id) var received: [ConversationChange] = [] let cancellable = store.changes.sink { received.append($0) } defer { cancellable.cancel() } store.clear(id) let conversation = store.conversation(for: id) #expect(conversation.messages.isEmpty) #expect(!conversation.containsMessage(withID: "m1")) #expect(store.conversationIDs.contains(id)) #expect(store.unreadConversations.contains(id)) // The ID index was cleared too: the same message can return. #expect(store.append(makeMessage(id: "m1", timestamp: 2), to: id)) guard case .cleared(id) = received.first else { Issue.record("expected .cleared first, got \(received)") return } } @Test("removeConversation drops messages, unread state, and selection") @MainActor func removeConversationDropsEverything() { let store = ConversationStore() let id = makeDirectConversationID("ee") store.append(makeMessage(id: "m1", timestamp: 1), to: id) store.markUnread(id) store.select(id) var received: [ConversationChange] = [] let cancellable = store.changes.sink { received.append($0) } defer { cancellable.cancel() } store.removeConversation(id) #expect(store.conversationsByID[id] == nil) #expect(store.conversationIDs.isEmpty) #expect(store.unreadConversations.isEmpty) #expect(store.selectedConversationID == nil) #expect(received.count == 1) guard case .removed(id) = received.first else { Issue.record("expected .removed, got \(received)") return } // Removing again is a no-op. store.removeConversation(id) #expect(received.count == 1) } @Test("clearAll removes every conversation and emits removals") @MainActor func clearAllRemovesEverything() { let store = ConversationStore() let direct = makeDirectConversationID("ff") store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh) store.append(makeMessage(id: "m2", timestamp: 2), to: direct) store.markUnread(direct) store.select(direct) var removed: [ConversationID] = [] let cancellable = store.changes.sink { change in if case .removed(let id) = change { removed.append(id) } } defer { cancellable.cancel() } store.clearAll() #expect(store.conversationsByID.isEmpty) #expect(store.conversationIDs.isEmpty) #expect(store.unreadConversations.isEmpty) #expect(store.selectedConversationID == nil) #expect(removed == [.mesh, direct]) } // MARK: - Change emission @Test("changes are emitted after state is consistent") @MainActor func changesEmittedAfterStateIsConsistent() { let store = ConversationStore() var observedCountsAtEmission: [Int] = [] var observedUnreadAtEmission: [Bool] = [] let cancellable = store.changes.sink { change in switch change { case .appended(let id, let message): // The appended message must already be visible at emission. observedCountsAtEmission.append(store.conversation(for: id).messages.count) #expect(store.conversation(for: id).containsMessage(withID: message.id)) case .unreadChanged(let id, let isUnread): #expect(store.unreadConversations.contains(id) == isUnread) observedUnreadAtEmission.append(isUnread) default: break } } defer { cancellable.cancel() } store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh) store.append(makeMessage(id: "m2", timestamp: 2), to: .mesh) store.markUnread(.mesh) store.markRead(.mesh) #expect(observedCountsAtEmission == [1, 2]) #expect(observedUnreadAtEmission == [true, false]) } @Test("cap policy follows the conversation kind") @MainActor func capPolicyByKind() { let store = ConversationStore() #expect(store.conversation(for: .mesh).cap == TransportConfig.meshTimelineCap) #expect(store.conversation(for: .geohash("u4pruyd")).cap == TransportConfig.geoTimelineCap) #expect(store.conversation(for: makeDirectConversationID("gg")).cap == TransportConfig.privateChatCap) } // MARK: - Publish isolation @Test("appending to one conversation does not publish another") @MainActor func perConversationPublishIsolation() { let store = ConversationStore() let a = makeDirectConversationID("aa") let b = makeDirectConversationID("bb") let conversationA = store.conversation(for: a) let conversationB = store.conversation(for: b) var aWillChangeCount = 0 var bWillChangeCount = 0 var cancellables = Set() 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..