From 7e86d2061fd6c25ea45e0536c6b8b69e1d36a014 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:20:19 -1000 Subject: [PATCH] Expand coverage for transport, chat, and media flows (#1056) * Expand coverage for transport, chat, and media flows * Stabilize transport and media coverage tests --------- Co-authored-by: jack --- bitchat/Services/NostrTransport.swift | 65 +- bitchat/Views/LocationNotesView.swift | 8 +- bitchatTests/BitchatPeerTests.swift | 83 +++ .../ChatViewModelExtensionsTests.swift | 682 ++++++++++++++++++ bitchatTests/Features/ImageUtilsTests.swift | 67 ++ bitchatTests/LocationNotesManagerTests.swift | 178 +++-- bitchatTests/Mocks/MockTransport.swift | 12 +- .../PreviewKeychainManagerTests.swift | 54 ++ bitchatTests/ProtocolContractTests.swift | 115 +++ bitchatTests/PublicTimelineStoreTests.swift | 72 ++ bitchatTests/ReadReceiptTests.swift | 64 ++ .../Services/AutocompleteServiceTests.swift | 50 ++ .../NoiseEncryptionServiceTests.swift | 231 ++++++ .../Services/NostrTransportTests.swift | 485 +++++++++++-- .../Services/PrivateChatManagerTests.swift | 198 +++++ .../TransferProgressManagerTests.swift | 125 ++++ bitchatTests/ViewSmokeTests.swift | 462 ++++++++++++ 17 files changed, 2841 insertions(+), 110 deletions(-) create mode 100644 bitchatTests/BitchatPeerTests.swift create mode 100644 bitchatTests/Features/ImageUtilsTests.swift create mode 100644 bitchatTests/PreviewKeychainManagerTests.swift create mode 100644 bitchatTests/ProtocolContractTests.swift create mode 100644 bitchatTests/PublicTimelineStoreTests.swift create mode 100644 bitchatTests/ReadReceiptTests.swift create mode 100644 bitchatTests/Services/AutocompleteServiceTests.swift create mode 100644 bitchatTests/Services/NoiseEncryptionServiceTests.swift create mode 100644 bitchatTests/Services/TransferProgressManagerTests.swift create mode 100644 bitchatTests/ViewSmokeTests.swift diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 860d2afd..b67919e2 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -4,6 +4,32 @@ import Combine // Minimal Nostr transport conforming to Transport for offline sending final class NostrTransport: Transport, @unchecked Sendable { + struct Dependencies { + let notificationCenter: NotificationCenter + let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship] + let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship? + let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship? + let currentIdentity: @MainActor () throws -> NostrIdentity? + let registerPendingGiftWrap: @MainActor (String) -> Void + let sendEvent: @MainActor (NostrEvent) -> Void + let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void + + static func live(idBridge: NostrIdentityBridge) -> Dependencies { + Dependencies( + notificationCenter: .default, + loadFavorites: { FavoritesPersistenceService.shared.favorites }, + favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) }, + favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) }, + currentIdentity: { try idBridge.getCurrentNostrIdentity() }, + registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) }, + sendEvent: { NostrRelayManager.shared.sendEvent($0) }, + scheduleAfter: { delay, action in + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) + } + ) + } + } + // Provide BLE short peer ID for BitChat embedding var senderPeerID = PeerID(str: "") @@ -17,20 +43,27 @@ final class NostrTransport: Transport, @unchecked Sendable { private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval private let keychain: KeychainManagerProtocol private let idBridge: NostrIdentityBridge + private let dependencies: Dependencies + private var favoriteStatusObserver: NSObjectProtocol? // Reachability Cache (thread-safe) private var reachablePeers: Set = [] private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent) @MainActor - init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) { + init( + keychain: KeychainManagerProtocol, + idBridge: NostrIdentityBridge, + dependencies: Dependencies? = nil + ) { self.keychain = keychain self.idBridge = idBridge + self.dependencies = dependencies ?? .live(idBridge: idBridge) setupObservers() // Synchronously warm the cache to avoid startup race - let favorites = FavoritesPersistenceService.shared.favorites + let favorites = self.dependencies.loadFavorites() let reachable = favorites.values .filter { $0.peerNostrPublicKey != nil } .map { PeerID(publicKey: $0.peerNoisePublicKey) } @@ -40,8 +73,14 @@ final class NostrTransport: Transport, @unchecked Sendable { } } + deinit { + if let favoriteStatusObserver { + dependencies.notificationCenter.removeObserver(favoriteStatusObserver) + } + } + private func setupObservers() { - NotificationCenter.default.addObserver( + favoriteStatusObserver = dependencies.notificationCenter.addObserver( forName: .favoriteStatusChanged, object: nil, queue: nil @@ -52,7 +91,7 @@ final class NostrTransport: Transport, @unchecked Sendable { private func refreshReachablePeers() { Task { @MainActor in - let favorites = FavoritesPersistenceService.shared.favorites + let favorites = dependencies.loadFavorites() let reachable = favorites.values .filter { $0.peerNostrPublicKey != nil } .map { PeerID(publicKey: $0.peerNoisePublicKey) } @@ -120,7 +159,7 @@ final class NostrTransport: Transport, @unchecked Sendable { Task { @MainActor in guard let recipientNpub = resolveRecipientNpub(for: peerID), let recipientHex = npubToHex(recipientNpub), - let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } + let senderIdentity = try? dependencies.currentIdentity() else { return } SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session) guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session) @@ -143,7 +182,7 @@ final class NostrTransport: Transport, @unchecked Sendable { Task { @MainActor in guard let recipientNpub = resolveRecipientNpub(for: peerID), let recipientHex = npubToHex(recipientNpub), - let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } + let senderIdentity = try? dependencies.currentIdentity() else { return } let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session) guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else { @@ -159,7 +198,7 @@ final class NostrTransport: Transport, @unchecked Sendable { Task { @MainActor in guard let recipientNpub = resolveRecipientNpub(for: peerID), let recipientHex = npubToHex(recipientNpub), - let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } + let senderIdentity = try? dependencies.currentIdentity() else { return } SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session) guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session) @@ -229,9 +268,9 @@ extension NostrTransport { return } if registerPending { - NostrRelayManager.registerPendingGiftWrap(id: event.id) + dependencies.registerPendingGiftWrap(event.id) } - NostrRelayManager.shared.sendEvent(event) + dependencies.sendEvent(event) } /// Must be called within a barrier on `queue` @@ -249,7 +288,7 @@ extension NostrTransport { defer { scheduleNextReadAck() } guard let recipientNpub = resolveRecipientNpub(for: item.peerID), let recipientHex = npubToHex(recipientNpub), - let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } + let senderIdentity = try? dependencies.currentIdentity() else { return } SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session) guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session) @@ -260,7 +299,7 @@ extension NostrTransport { } private func scheduleNextReadAck() { - DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in + dependencies.scheduleAfter(readAckInterval) { [weak self] in self?.queue.async(flags: .barrier) { [weak self] in self?.isSendingReadAcks = false self?.processReadQueueIfNeeded() @@ -271,12 +310,12 @@ extension NostrTransport { @MainActor private func resolveRecipientNpub(for peerID: PeerID) -> String? { if let noiseKey = Data(hexString: peerID.id), - let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + let fav = dependencies.favoriteStatusForNoiseKey(noiseKey), let npub = fav.peerNostrPublicKey { return npub } if peerID.id.count == 16, - let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), + let fav = dependencies.favoriteStatusForPeerID(peerID), let npub = fav.peerNostrPublicKey { return npub } diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift index 63d57c81..1b77d80d 100644 --- a/bitchat/Views/LocationNotesView.swift +++ b/bitchat/Views/LocationNotesView.swift @@ -12,11 +12,15 @@ struct LocationNotesView: View { @Environment(\.dismiss) private var dismiss @State private var draft: String = "" - init(geohash: String, onNotesCountChanged: ((Int) -> Void)? = nil) { + init( + geohash: String, + onNotesCountChanged: ((Int) -> Void)? = nil, + manager: LocationNotesManager? = nil + ) { let gh = geohash.lowercased() self.geohash = gh self.onNotesCountChanged = onNotesCountChanged - _manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh)) + _manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) } private var backgroundColor: Color { colorScheme == .dark ? .black : .white } diff --git a/bitchatTests/BitchatPeerTests.swift b/bitchatTests/BitchatPeerTests.swift new file mode 100644 index 00000000..546a7f75 --- /dev/null +++ b/bitchatTests/BitchatPeerTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +@testable import bitchat + +@Suite("BitchatPeer Tests") +struct BitchatPeerTests { + typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship + + @Test("Connection state prioritizes bluetooth, mesh, nostr, then offline") + func connectionStatePriorityIsCorrect() { + let peerID = PeerID(str: "0123456789abcdef") + let noiseKey = Data((0..<32).map(UInt8.init)) + let mutual = makeRelationship(isFavorite: true, theyFavoritedUs: true) + + let bluetooth = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: true, isReachable: true) + let mesh = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: true) + var nostr = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false) + nostr.favoriteStatus = mutual + let offline = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false) + + #expect(bluetooth.connectionState == .bluetoothConnected) + #expect(mesh.connectionState == .meshReachable) + #expect(nostr.connectionState == .nostrAvailable) + #expect(offline.connectionState == .offline) + } + + @Test("Display name falls back to peer prefix and offline icon reflects inbound favorite") + func displayNameAndOfflineIconUseDerivedState() { + let peerID = PeerID(str: "fedcba9876543210") + let noiseKey = Data((32..<64).map(UInt8.init)) + var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "", isConnected: false, isReachable: false) + peer.favoriteStatus = makeRelationship(isFavorite: false, theyFavoritedUs: true) + + #expect(peer.displayName == String(peerID.id.prefix(8))) + #expect(peer.statusIcon == "🌙") + } + + @Test("Mutual offline peers show Nostr icon") + func mutualFavoriteOfflinePeerShowsNostrIcon() { + let peerID = PeerID(str: "0011223344556677") + let noiseKey = Data((64..<96).map(UInt8.init)) + var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "Peer", isConnected: false, isReachable: false) + peer.favoriteStatus = makeRelationship(isFavorite: true, theyFavoritedUs: true) + + #expect(peer.statusIcon == "🌐") + #expect(peer.isFavorite) + #expect(peer.isMutualFavorite) + #expect(peer.theyFavoritedUs) + } + + @Test("Equality is based only on peer ID") + func equalityUsesPeerIDOnly() { + let peerID = PeerID(str: "8899aabbccddeeff") + let first = BitchatPeer( + peerID: peerID, + noisePublicKey: Data(repeating: 1, count: 32), + nickname: "First", + isConnected: false, + isReachable: false + ) + let second = BitchatPeer( + peerID: peerID, + noisePublicKey: Data(repeating: 2, count: 32), + nickname: "Second", + isConnected: true, + isReachable: true + ) + + #expect(first == second) + } + + private func makeRelationship(isFavorite: Bool, theyFavoritedUs: Bool) -> FavoriteRelationship { + FavoriteRelationship( + peerNoisePublicKey: Data(repeating: 7, count: 32), + peerNostrPublicKey: "npub1example", + peerNickname: "Peer", + isFavorite: isFavorite, + theyFavoritedUs: theyFavoritedUs, + favoritedAt: Date(timeIntervalSince1970: 1), + lastUpdated: Date(timeIntervalSince1970: 2) + ) + } +} diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 2507e89b..1f35736e 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -8,6 +8,11 @@ import Testing import Foundation import Combine +#if os(iOS) +import UIKit +#else +import AppKit +#endif @testable import bitchat // MARK: - Test Helpers @@ -404,6 +409,274 @@ struct ChatViewModelNostrExtensionTests { #expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache")) } + + @Test @MainActor + func handleNostrEvent_presenceTracksParticipantWithoutTimelineMessage() async throws { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + let identity = try NostrIdentity.generate() + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + + let event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .geohashPresence, + tags: [["g", geohash]], + content: "" + ) + let signed = try event.sign(with: identity.schnorrSigningKey()) + + viewModel.handleNostrEvent(signed) + + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(viewModel.geohashParticipantCount(for: geohash) >= 1) + viewModel.publicMessagePipeline.flushIfNeeded() + #expect(viewModel.messages.isEmpty) + } + + @Test @MainActor + func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws { + let (viewModel, _) = makeTestableViewModel() + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let convKey = PeerID(nostr_: sender.publicKeyHex) + let messageID = "geo-ack-delivered" + + viewModel.privateChats[convKey] = [ + BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Hello", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Friend", + senderPeerID: viewModel.meshService.myPeerID, + deliveryStatus: .sent + ) + ] + + let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) + let giftWrap = try NostrProtocol.createPrivateMessage( + content: content, + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + viewModel.subscribeGiftWrap(giftWrap, id: recipient) + + let didUpdate = await TestHelpers.waitUntil( + { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, + timeout: 0.5 + ) + #expect(didUpdate) + } + + @Test @MainActor + func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws { + let (viewModel, _) = makeTestableViewModel() + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let convKey = PeerID(nostr_: sender.publicKeyHex) + let messageID = "geo-ack-read" + + viewModel.privateChats[convKey] = [ + BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Hello", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Friend", + senderPeerID: viewModel.meshService.myPeerID, + deliveryStatus: .delivered(to: "Friend", at: Date()) + ) + ] + + let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) + let giftWrap = try NostrProtocol.createPrivateMessage( + content: content, + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + viewModel.subscribeGiftWrap(giftWrap, id: recipient) + + let didUpdate = await TestHelpers.waitUntil( + { isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, + timeout: 0.5 + ) + #expect(didUpdate) + } + + @Test @MainActor + func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws { + let (viewModel, _) = makeTestableViewModel() + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let messageID = "gift-private" + let convKey = PeerID(nostr_: sender.publicKeyHex) + + let content = try privateMessageContent( + text: "Hello from gift wrap", + messageID: messageID, + senderPeerID: PeerID(str: "0123456789abcdef") + ) + let giftWrap = try NostrProtocol.createPrivateMessage( + content: content, + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + viewModel.handleGiftWrap(giftWrap, id: recipient) + + let didStore = await TestHelpers.waitUntil( + { viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" }, + timeout: 0.5 + ) + #expect(didStore) + #expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex) + #expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) + } + + @Test @MainActor + func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws { + let (viewModel, _) = makeTestableViewModel() + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let messageID = "gift-blocked" + let convKey = PeerID(nostr_: sender.publicKeyHex) + + viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true) + + let content = try privateMessageContent( + text: "Blocked", + messageID: messageID, + senderPeerID: PeerID(str: "0123456789abcdef") + ) + let giftWrap = try NostrProtocol.createPrivateMessage( + content: content, + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + viewModel.handleGiftWrap(giftWrap, id: recipient) + + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(viewModel.privateChats[convKey] == nil) + #expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) + } + + @Test @MainActor + func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws { + let (viewModel, _) = makeTestableViewModel() + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let convKey = PeerID(nostr_: sender.publicKeyHex) + let messageID = "gift-delivered" + + viewModel.privateChats[convKey] = [ + BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Hello", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Friend", + senderPeerID: viewModel.meshService.myPeerID, + deliveryStatus: .sent + ) + ] + + let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) + let giftWrap = try NostrProtocol.createPrivateMessage( + content: content, + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + viewModel.handleGiftWrap(giftWrap, id: recipient) + + let didUpdate = await TestHelpers.waitUntil( + { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, + timeout: 0.5 + ) + #expect(didUpdate) + } + + @Test @MainActor + func findNoiseKey_matchesFavoriteStoredAsNpub() async throws { + let (viewModel, _) = makeTestableViewModel() + let identity = try NostrIdentity.generate() + let noiseKey = Data((0..<32).map { UInt8(($0 + 80) & 0xFF) }) + + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: identity.npub, + peerNickname: "Alice" + ) + defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) } + + #expect(viewModel.findNoiseKey(for: identity.publicKeyHex) == noiseKey) + } + + @Test @MainActor + func findNoiseKey_matchesFavoriteStoredAsHex() async { + let (viewModel, _) = makeTestableViewModel() + let nostrHex = String(repeating: "ab", count: 32) + let noiseKey = Data((0..<32).map { UInt8(($0 + 112) & 0xFF) }) + + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nostrHex, + peerNickname: "Bob" + ) + defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) } + + #expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey) + } + + @Test @MainActor + func handleFavoriteNotification_updatesFavoriteAssociation() async throws { + let (viewModel, _) = makeTestableViewModel() + let identity = try NostrIdentity.generate() + let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) }) + + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: identity.npub, + peerNickname: "Before" + ) + defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) } + + viewModel.handleFavoriteNotification( + content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice", + from: identity.publicKeyHex + ) + + let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) + #expect(relationship?.peerNickname == "Alice") + #expect(relationship?.peerNostrPublicKey == identity.npub) + #expect(relationship?.isFavorite == true) + } + + @Test @MainActor + func geohashDMHelpers_exposeMappingAndDisplayName() async { + let (viewModel, _) = makeTestableViewModel() + let nostrHex = String(repeating: "cd", count: 32) + let convKey = PeerID(nostr_: nostrHex) + + viewModel.geoNicknames[nostrHex] = "Alice" + viewModel.startGeohashDM(withPubkeyHex: nostrHex) + + #expect(viewModel.selectedPrivateChatPeer == convKey) + #expect(viewModel.fullNostrHex(forSenderPeerID: convKey) == nostrHex) + #expect(viewModel.geohashDisplayName(for: convKey).hasPrefix("Alice")) + #expect(viewModel.nostrPubkeyForDisplayName("Alice") == nostrHex) + } } // MARK: - Geohash Queue Tests @@ -449,6 +722,302 @@ struct ChatViewModelGeoDMTests { #expect(viewModel.privateChats[convKey]?.count == 1) #expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) } + + @Test @MainActor + func sendGeohashDM_requiresActiveLocationChannel() async { + let (viewModel, _) = makeTestableViewModel() + let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000001") + + viewModel.sendGeohashDM("hello", to: convKey) + + #expect(viewModel.privateChats[convKey] == nil) + #expect(viewModel.messages.count == 1) + #expect(viewModel.messages.last?.sender == "system") + } + + @Test @MainActor + func sendGeohashDM_missingRecipientMapping_marksFailed() async { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000002") + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + viewModel.sendGeohashDM("hello", to: convKey) + + #expect(viewModel.privateChats[convKey]?.count == 1) + #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus)) + } + + @Test @MainActor + func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessage() async { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + let recipientHex = "0000000000000000000000000000000000000000000000000000000000000003" + let convKey = PeerID(nostr_: recipientHex) + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + viewModel.nostrKeyMapping[convKey] = recipientHex + viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true) + + viewModel.sendGeohashDM("hello", to: convKey) + + #expect(viewModel.privateChats[convKey]?.count == 1) + #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus)) + #expect(viewModel.messages.contains(where: { $0.sender == "system" })) + } + + @Test @MainActor + func handlePrivateMessage_geohashViewingConversationRecordsReadReceipt() async throws { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000004" + let convKey = PeerID(nostr_: senderPubkey) + let messageID = "pm-viewing" + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + viewModel.selectedPrivateChatPeer = convKey + + let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash) + let packet = PrivateMessagePacket(messageID: messageID, content: "Hello") + let payloadData = try #require(packet.encode(), "Failed to encode private message") + let payload = NoisePayload(type: .privateMessage, data: payloadData) + + viewModel.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: convKey, + id: identity, + messageTimestamp: Date() + ) + + #expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) + #expect(viewModel.sentReadReceipts.contains(messageID)) + #expect(!viewModel.unreadPrivateMessages.contains(convKey)) + } +} + +struct ChatViewModelMediaTransferTests { + + @Test @MainActor + func handleTransferEvent_updatesPrivateMessageProgressAndClearsMappingOnCompletion() async { + let (viewModel, _) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10") + let message = viewModel.enqueueMediaMessage(content: "[voice] clip.m4a", targetPeer: peerID) + let transferID = "transfer-1" + + viewModel.registerTransfer(transferId: transferID, messageID: message.id) + viewModel.handleTransferEvent(.started(id: transferID, totalFragments: 4)) + #expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 0, total: 4)) + + viewModel.handleTransferEvent(.updated(id: transferID, sentFragments: 2, totalFragments: 4)) + #expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 2, total: 4)) + + viewModel.handleTransferEvent(.completed(id: transferID, totalFragments: 4)) + #expect(isSent(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id))) + #expect(viewModel.messageIDToTransferId[message.id] == nil) + #expect(viewModel.transferIdToMessageIDs[transferID] == nil) + } + + @Test @MainActor + func handleTransferEvent_cancelledRemovesOutgoingMessage() async { + let (viewModel, _) = makeTestableViewModel() + let peerID = PeerID(str: "1111111111111111111111111111111111111111111111111111111111111111") + let message = viewModel.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: peerID) + let transferID = "transfer-2" + + viewModel.registerTransfer(transferId: transferID, messageID: message.id) + viewModel.handleTransferEvent(.cancelled(id: transferID, sentFragments: 1, totalFragments: 3)) + + #expect(viewModel.privateChats[peerID]?.contains(where: { $0.id == message.id }) != true) + #expect(viewModel.messageIDToTransferId[message.id] == nil) + } + + @Test @MainActor + func sendVoiceNote_outsideAllowedContextDeletesTempFile() async throws { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a") + + try Data("voice".utf8).write(to: url) + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + + viewModel.sendVoiceNote(at: url) + + #expect(!FileManager.default.fileExists(atPath: url.path)) + #expect(viewModel.messages.contains(where: { $0.sender == "system" })) + } + + @Test @MainActor + func sendImage_outsideAllowedContextRunsCleanup() async { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + var cleanupCalled = false + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + viewModel.sendImage(from: URL(fileURLWithPath: "/tmp/ignored.jpg")) { + cleanupCalled = true + } + + #expect(cleanupCalled) + #expect(viewModel.messages.contains(where: { $0.sender == "system" })) + } + + @Test @MainActor + func sendVoiceNote_privateChatUsesPrivateFileTransfer() async throws { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "2222222222222222222222222222222222222222222222222222222222222222") + let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a") + try Data("voice payload".utf8).write(to: url, options: .atomic) + defer { try? FileManager.default.removeItem(at: url) } + + viewModel.selectedPrivateChatPeer = peerID + viewModel.sendVoiceNote(at: url) + + let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 0.5) + #expect(didSend) + #expect(transport.sentPrivateFiles.first?.peerID == peerID) + #expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true) + #expect(viewModel.messageIDToTransferId.count == 1) + #expect(viewModel.transferIdToMessageIDs.count == 1) + } + + @Test @MainActor + func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "3333333333333333333333333333333333333333333333333333333333333333") + let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-too-large-\(UUID().uuidString).m4a") + try Data(repeating: 0x55, count: FileTransferLimits.maxVoiceNoteBytes + 1).write(to: url, options: .atomic) + + viewModel.selectedPrivateChatPeer = peerID + viewModel.sendVoiceNote(at: url) + + let didFail = await TestHelpers.waitUntil({ + isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus) + }, timeout: 0.5) + #expect(didFail) + #expect(!FileManager.default.fileExists(atPath: url.path)) + #expect(transport.sentPrivateFiles.isEmpty) + } + + @Test @MainActor + func sendImage_privateChatProcessesAndTransfersImage() async throws { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "4444444444444444444444444444444444444444444444444444444444444444") + let sourceURL = try makeTemporaryImageURL() + defer { try? FileManager.default.removeItem(at: sourceURL) } + + viewModel.selectedPrivateChatPeer = peerID + viewModel.sendImage(from: sourceURL) + + let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 1.0) + #expect(didSend) + #expect(transport.sentPrivateFiles.first?.peerID == peerID) + #expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg") + #expect(viewModel.privateChats[peerID]?.last?.content.contains("[image]") == true) + #expect(viewModel.messageIDToTransferId.count == 1) + } + + @Test @MainActor + func sendImage_invalidSourceAddsFailureSystemMessage() async throws { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "5555555555555555555555555555555555555555555555555555555555555555") + let url = FileManager.default.temporaryDirectory.appendingPathComponent("invalid-\(UUID().uuidString).jpg") + try Data("not-an-image".utf8).write(to: url, options: .atomic) + defer { try? FileManager.default.removeItem(at: url) } + + viewModel.selectedPrivateChatPeer = peerID + viewModel.sendImage(from: url) + + let didNotify = await TestHelpers.waitUntil({ + viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") }) + }, timeout: 2.0) + #expect(didNotify) + #expect(transport.sentPrivateFiles.isEmpty) + #expect(viewModel.privateChats[peerID]?.isEmpty != false) + } + + @Test @MainActor + func clearTransferMapping_promotesQueuedTransferForSameID() async { + let (viewModel, _) = makeTestableViewModel() + viewModel.registerTransfer(transferId: "transfer-queue", messageID: "first") + viewModel.registerTransfer(transferId: "transfer-queue", messageID: "second") + + viewModel.clearTransferMapping(for: "first") + + #expect(viewModel.messageIDToTransferId["first"] == nil) + #expect(viewModel.transferIdToMessageIDs["transfer-queue"] == ["second"]) + #expect(viewModel.messageIDToTransferId["second"] == "transfer-queue") + } + + @Test @MainActor + func cancelMediaSend_cancelsActiveTransferRemovesMessageAndDeletesFile() async throws { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "6666666666666666666666666666666666666666666666666666666666666666") + let fileName = "cancel-\(UUID().uuidString).m4a" + let fileURL = try mediaFileURL(subdirectory: "voicenotes/outgoing", fileName: fileName) + try Data("cancel me".utf8).write(to: fileURL, options: .atomic) + + let message = BitchatMessage( + id: "cancel-msg", + sender: viewModel.nickname, + content: "[voice] \(fileName)", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: viewModel.meshService.myPeerID, + deliveryStatus: .sending + ) + viewModel.privateChats[peerID] = [message] + viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id) + + viewModel.cancelMediaSend(messageID: message.id) + + #expect(transport.cancelledTransfers == ["transfer-cancel"]) + #expect(viewModel.privateChats[peerID] == nil) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test @MainActor + func deleteMediaMessage_removesStoredMessageAndCleansImageFile() async throws { + let (viewModel, _) = makeTestableViewModel() + let peerID = PeerID(str: "7777777777777777777777777777777777777777777777777777777777777777") + let fileName = "delete-\(UUID().uuidString).jpg" + let fileURL = try mediaFileURL(subdirectory: "images/outgoing", fileName: fileName) + try Data("image bytes".utf8).write(to: fileURL, options: .atomic) + + let message = BitchatMessage( + id: "delete-msg", + sender: viewModel.nickname, + content: "[image] \(fileName)", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: viewModel.meshService.myPeerID, + deliveryStatus: .sent + ) + viewModel.privateChats[peerID] = [message] + viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id) + + viewModel.deleteMediaMessage(messageID: message.id) + + #expect(viewModel.privateChats[peerID] == nil) + #expect(viewModel.messageIDToTransferId[message.id] == nil) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test @MainActor + func makeTransferID_isPrefixedByMessageIDAndUnique() async { + let (viewModel, _) = makeTestableViewModel() + + let first = viewModel.makeTransferID(messageID: "base") + let second = viewModel.makeTransferID(messageID: "base") + + #expect(first.hasPrefix("base-")) + #expect(second.hasPrefix("base-")) + #expect(first != second) + } } private func base64URLEncode(_ data: Data) -> String { @@ -457,3 +1026,116 @@ private func base64URLEncode(_ data: Data) -> String { .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } + +private func ackContent(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) throws -> String { + if let content = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( + type: type, + messageID: messageID, + senderPeerID: senderPeerID + ) { + return content + } + throw ChatViewModelExtensionsTestError.invalidAckContent +} + +private func privateMessageContent(text: String, messageID: String, senderPeerID: PeerID) throws -> String { + if let content = NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content: text, + messageID: messageID, + senderPeerID: senderPeerID + ) { + return content + } + throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent +} + +@MainActor +private func deliveryStatus(in viewModel: ChatViewModel, peerID: PeerID, messageID: String) -> DeliveryStatus? { + viewModel.privateChats[peerID]?.first(where: { $0.id == messageID })?.deliveryStatus +} + +private func isFailed(status: DeliveryStatus?) -> Bool { + if case .failed = status { + return true + } + return false +} + +private func isDelivered(status: DeliveryStatus?) -> Bool { + if case .delivered = status { + return true + } + return false +} + +private func isRead(status: DeliveryStatus?) -> Bool { + if case .read = status { + return true + } + return false +} + +private func isSent(status: DeliveryStatus?) -> Bool { + if case .sent = status { + return true + } + return false +} + +private func isPartiallyDelivered(status: DeliveryStatus?, reached: Int, total: Int) -> Bool { + if case .partiallyDelivered(let actualReached, let actualTotal) = status { + return actualReached == reached && actualTotal == total + } + return false +} + +private enum ChatViewModelExtensionsTestError: Error { + case invalidAckContent + case invalidPrivateMessageContent +} + +private func mediaFileURL(subdirectory: String, fileName: String) throws -> URL { + let base = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent("files", isDirectory: true) + let directory = base.appendingPathComponent(subdirectory, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appendingPathComponent(fileName) +} + +private func makeTemporaryImageURL() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("image-\(UUID().uuidString).png") + let data = try makeImageData() + try data.write(to: url, options: .atomic) + return url +} + +private func makeImageData() throws -> Data { + #if os(iOS) + let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64)).image { context in + UIColor.systemTeal.setFill() + context.fill(CGRect(x: 0, y: 0, width: 64, height: 64)) + } + guard let data = image.pngData() else { + throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent + } + return data + #else + let image = NSImage(size: CGSize(width: 64, height: 64)) + image.lockFocus() + NSColor.systemTeal.setFill() + NSBezierPath(rect: CGRect(x: 0, y: 0, width: 64, height: 64)).fill() + image.unlockFocus() + guard + let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let data = bitmap.representation(using: .png, properties: [:]) + else { + throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent + } + return data + #endif +} diff --git a/bitchatTests/Features/ImageUtilsTests.swift b/bitchatTests/Features/ImageUtilsTests.swift new file mode 100644 index 00000000..880403bb --- /dev/null +++ b/bitchatTests/Features/ImageUtilsTests.swift @@ -0,0 +1,67 @@ +import Testing +import Foundation +#if os(iOS) +import UIKit +#else +import AppKit +#endif +@testable import bitchat + +private func makeTemporaryFileURL(_ name: String) -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(name) +} + +#if os(iOS) +private func makePlatformImage(size: CGSize) -> UIImage { + UIGraphicsImageRenderer(size: size).image { context in + UIColor.systemTeal.setFill() + context.fill(CGRect(origin: .zero, size: size)) + } +} +#else +private func makePlatformImage(size: CGSize) -> NSImage { + let image = NSImage(size: size) + image.lockFocus() + NSColor.systemTeal.setFill() + NSBezierPath(rect: CGRect(origin: .zero, size: size)).fill() + image.unlockFocus() + return image +} +#endif + +struct ImageUtilsTests { + @Test + func processImage_rejectsOversizedSourceFile() throws { + let url = makeTemporaryFileURL("image-too-large.bin") + try Data(repeating: 0xFF, count: 10 * 1024 * 1024 + 1).write(to: url, options: .atomic) + defer { try? FileManager.default.removeItem(at: url) } + + #expect(throws: ImageUtilsError.self) { + try ImageUtils.processImage(at: url) + } + } + + @Test + func processImage_rejectsInvalidImageData() throws { + let url = makeTemporaryFileURL("image-invalid.bin") + try Data("not-an-image".utf8).write(to: url, options: .atomic) + defer { try? FileManager.default.removeItem(at: url) } + + #expect(throws: ImageUtilsError.self) { + try ImageUtils.processImage(at: url) + } + } + + @Test + func processImage_writesCompressedJpeg() throws { + let image = makePlatformImage(size: CGSize(width: 1024, height: 768)) + let outputURL = try ImageUtils.processImage(image, maxDimension: 256) + defer { try? FileManager.default.removeItem(at: outputURL) } + + let data = try Data(contentsOf: outputURL) + + #expect(outputURL.pathExtension.lowercased() == "jpg") + #expect(data.starts(with: Data([0xFF, 0xD8]))) + #expect(data.count > 0) + } +} diff --git a/bitchatTests/LocationNotesManagerTests.swift b/bitchatTests/LocationNotesManagerTests.swift index 6f593b4e..f8efb50a 100644 --- a/bitchatTests/LocationNotesManagerTests.swift +++ b/bitchatTests/LocationNotesManagerTests.swift @@ -4,49 +4,47 @@ import Foundation @MainActor struct LocationNotesManagerTests { -// func testSubscribeWithoutRelaysSetsNoRelaysState() { -// var subscribeCalled = false -// let deps = LocationNotesDependencies( -// relayLookup: { _, _ in [] }, -// subscribe: { _, _, _, _, _ in -// subscribeCalled = true -// }, -// unsubscribe: { _ in }, -// sendEvent: { _, _ in }, -// deriveIdentity: { _ in fatalError("should not derive identity") }, -// now: { Date() } -// ) -// -// let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) -// -// XCTAssertFalse(subscribeCalled) -// XCTAssertEqual(manager.state, .noRelays) -// XCTAssertTrue(manager.initialLoadComplete) -// XCTAssertEqual(manager.errorMessage, String(localized: "location_notes.error.no_relays")) -// // Make sure we're getting an actual translated value and not the localization key -// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays") -// } -// -// func testSendWhenNoRelaysSurfacesError() { -// var sendCalled = false -// let deps = LocationNotesDependencies( -// relayLookup: { _, _ in [] }, -// subscribe: { _, _, _, _, _ in }, -// unsubscribe: { _ in }, -// sendEvent: { _, _ in sendCalled = true }, -// deriveIdentity: { _ in throw TestError.shouldNotDerive }, -// now: { Date() } -// ) -// -// let manager = LocationNotesManager(geohash: "zzzzzzzz", dependencies: deps) -// manager.send(content: "hello", nickname: "tester") -// -// XCTAssertFalse(sendCalled) -// XCTAssertEqual(manager.state, .noRelays) -// XCTAssertEqual(manager.errorMessage, String(localized: "location_notes.error.no_relays")) -// // Make sure we're getting an actual translated value and not the localization key -// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays") -// } + @Test + func subscribeWithoutRelays_setsNoRelaysState() { + var subscribeCalled = false + let deps = LocationNotesDependencies( + relayLookup: { _, _ in [] }, + subscribe: { _, _, _, _, _ in + subscribeCalled = true + }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in try NostrIdentity.generate() }, + now: { Date() } + ) + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + + #expect(subscribeCalled == false) + #expect(manager.state == .noRelays) + #expect(manager.initialLoadComplete) + #expect(manager.errorMessage == String(localized: "location_notes.error.no_relays")) + } + + @Test + func sendWithoutRelays_surfacesNoRelaysError() { + var sendCalled = false + let deps = LocationNotesDependencies( + relayLookup: { _, _ in [] }, + subscribe: { _, _, _, _, _ in }, + unsubscribe: { _ in }, + sendEvent: { _, _ in sendCalled = true }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { Date() } + ) + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + manager.send(content: "hello", nickname: "tester") + + #expect(sendCalled == false) + #expect(manager.state == .noRelays) + #expect(manager.errorMessage == String(localized: "location_notes.error.no_relays")) + } @Test func subscribeUsesGeoRelaysAndAppendsNotes() throws { var relaysCaptured: [String] = [] @@ -88,6 +86,100 @@ struct LocationNotesManagerTests { #expect(manager.notes.first?.content == "hi") } + @Test + func setGeohash_invalidValueIsIgnored() { + var subscribeCount = 0 + let deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, _, _ in + subscribeCount += 1 + }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in try NostrIdentity.generate() }, + now: { Date() } + ) + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + manager.setGeohash("not-valid") + + #expect(manager.geohash == "u4pruydq") + #expect(subscribeCount == 1) + } + + @Test + func refreshAndCancel_manageSubscriptions() { + var subscribeIDs: [String] = [] + var unsubscribedIDs: [String] = [] + let deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, id, _, _, _ in + subscribeIDs.append(id) + }, + unsubscribe: { id in + unsubscribedIDs.append(id) + }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in try NostrIdentity.generate() }, + now: { Date() } + ) + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + manager.refresh() + manager.cancel() + + #expect(subscribeIDs.count == 2) + #expect(unsubscribedIDs.count == 2) + #expect(manager.state == .idle) + #expect(manager.errorMessage == nil) + } + + @Test + func send_successCreatesLocalEchoAndClearsError() throws { + var sentEvents: [NostrEvent] = [] + let identity = try NostrIdentity.generate() + let deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, _, _ in }, + unsubscribe: { _ in }, + sendEvent: { event, _ in + sentEvents.append(event) + }, + deriveIdentity: { _ in identity }, + now: { Date(timeIntervalSince1970: 123_456) } + ) + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + manager.send(content: " hello note ", nickname: "Builder") + + #expect(sentEvents.count == 1) + #expect(manager.state == .ready) + #expect(manager.errorMessage == nil) + #expect(manager.notes.first?.content == "hello note") + #expect(manager.notes.first?.displayName.hasPrefix("Builder#") == true) + } + + @Test + func send_failureFormatsErrorMessageAndClearErrorRemovesIt() { + let deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, _, _ in }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { Date() } + ) + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + manager.send(content: "hello", nickname: "Builder") + + #expect(manager.errorMessage?.isEmpty == false) + + manager.clearError() + + #expect(manager.errorMessage == nil) + } + private enum TestError: Error { case shouldNotDerive } diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index a22bc15d..609467b3 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -35,6 +35,9 @@ final class MockTransport: Transport { private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = [] private(set) var sentDeliveryAcks: [(messageID: String, peerID: PeerID)] = [] private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] + private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = [] + private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = [] + private(set) var cancelledTransfers: [String] = [] private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var startServicesCallCount = 0 @@ -139,15 +142,15 @@ final class MockTransport: Transport { } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { - // Not tracked for current tests + sentBroadcastFiles.append((packet, transferId)) } func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { - // Not tracked for current tests + sentPrivateFiles.append((packet, peerID, transferId)) } func cancelTransfer(_ transferId: String) { - // Not tracked for current tests + cancelledTransfers.append(transferId) } func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { @@ -167,6 +170,9 @@ final class MockTransport: Transport { sentReadReceipts.removeAll() sentDeliveryAcks.removeAll() sentFavoriteNotifications.removeAll() + sentBroadcastFiles.removeAll() + sentPrivateFiles.removeAll() + cancelledTransfers.removeAll() sentVerifyChallenges.removeAll() sentVerifyResponses.removeAll() startServicesCallCount = 0 diff --git a/bitchatTests/PreviewKeychainManagerTests.swift b/bitchatTests/PreviewKeychainManagerTests.swift new file mode 100644 index 00000000..3c110436 --- /dev/null +++ b/bitchatTests/PreviewKeychainManagerTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +@testable import bitchat + +@Suite("PreviewKeychainManager Tests") +struct PreviewKeychainManagerTests { + + @Test("Preview keychain manager stores identity and service-scoped data in memory") + func previewKeychainManagerRoundTripsData() { + let manager = PreviewKeychainManager() + let identityKey = Data([1, 2, 3, 4]) + let serviceKey = "preview-service" + let scopedData = Data([9, 8, 7, 6]) + + #expect(!manager.verifyIdentityKeyExists()) + #expect(manager.saveIdentityKey(identityKey, forKey: "noiseStaticKey")) + #expect(manager.getIdentityKey(forKey: "noiseStaticKey") == identityKey) + #expect(manager.saveIdentityKey(identityKey, forKey: "identity_noiseStaticKey")) + #expect(manager.verifyIdentityKeyExists()) + + if case .success(let stored) = manager.getIdentityKeyWithResult(forKey: "noiseStaticKey") { + #expect(stored == identityKey) + } else { + Issue.record("Expected stored preview identity key") + } + + if case .success = manager.saveIdentityKeyWithResult(Data([5, 6, 7]), forKey: "ed25519SigningKey") { + } else { + Issue.record("Expected preview keychain save to succeed") + } + + manager.save(key: "blob", data: scopedData, service: serviceKey, accessible: nil) + #expect(manager.load(key: "blob", service: serviceKey) == scopedData) + manager.delete(key: "blob", service: serviceKey) + #expect(manager.load(key: "blob", service: serviceKey) == nil) + + var secretData = Data([4, 3, 2, 1]) + var secretString = "secret" + manager.secureClear(&secretData) + manager.secureClear(&secretString) + #expect(secretData == Data([4, 3, 2, 1])) + #expect(secretString == "secret") + + #expect(manager.deleteIdentityKey(forKey: "noiseStaticKey")) + #expect(manager.deleteIdentityKey(forKey: "identity_noiseStaticKey")) + #expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil) + #expect(manager.deleteAllKeychainData()) + + if case .itemNotFound = manager.getIdentityKeyWithResult(forKey: "ed25519SigningKey") { + } else { + Issue.record("Expected preview keychain to be empty after deleteAllKeychainData") + } + } +} diff --git a/bitchatTests/ProtocolContractTests.swift b/bitchatTests/ProtocolContractTests.swift new file mode 100644 index 00000000..416ba62e --- /dev/null +++ b/bitchatTests/ProtocolContractTests.swift @@ -0,0 +1,115 @@ +import Testing +import Foundation +import Combine +import CoreBluetooth +@testable import bitchat + +private final class DefaultDelegateProbe: BitchatDelegate { + func didReceiveMessage(_ message: BitchatMessage) {} + func didConnectToPeer(_ peerID: PeerID) {} + func didDisconnectFromPeer(_ peerID: PeerID) {} + func didUpdatePeerList(_ peers: [PeerID]) {} + func didUpdateBluetoothState(_ state: CBManagerState) {} +} + +private final class DefaultTransportProbe: Transport { + weak var delegate: BitchatDelegate? + weak var peerEventsDelegate: TransportPeerEventsDelegate? + + let subject = CurrentValueSubject<[TransportPeerSnapshot], Never>([]) + let myPeerID = PeerID(str: "0011223344556677") + var myNickname = "Tester" + private let keychain = MockKeychain() + private(set) var sentMessages: [(content: String, mentions: [String])] = [] + + var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { + subject.eraseToAnyPublisher() + } + + func currentPeerSnapshots() -> [TransportPeerSnapshot] { subject.value } + func setNickname(_ nickname: String) { myNickname = nickname } + func startServices() {} + func stopServices() {} + func emergencyDisconnectAll() {} + func isPeerConnected(_ peerID: PeerID) -> Bool { false } + func isPeerReachable(_ peerID: PeerID) -> Bool { false } + func peerNickname(peerID: PeerID) -> String? { nil } + func getPeerNicknames() -> [PeerID: String] { [:] } + func getFingerprint(for peerID: PeerID) -> String? { nil } + func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } + func triggerHandshake(with peerID: PeerID) {} + func getNoiseService() -> NoiseEncryptionService { NoiseEncryptionService(keychain: keychain) } + func sendMessage(_ content: String, mentions: [String]) { sentMessages.append((content, mentions)) } + func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {} + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {} + func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {} + func sendBroadcastAnnounce() {} + func sendDeliveryAck(for messageID: String, to peerID: PeerID) {} +} + +struct ProtocolContractTests { + @Test + func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() { + #expect(CommandInfo.message.id == "dm") + #expect(CommandInfo.message.alias == "/dm") + #expect(CommandInfo.message.placeholder != nil) + #expect(CommandInfo.clear.placeholder == nil) + #expect(CommandInfo.favorite.description.isEmpty == false) + #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false) + #expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite)) + #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite)) + } + + @Test + func protocolEnums_andDelegateDefaults_haveStableContracts() { + let delegate = DefaultDelegateProbe() + let peerID = PeerID(str: "8899aabbccddeeff") + + #expect(MessageType.requestSync.description == "requestSync") + #expect(NoisePayloadType.verifyResponse.description == "verifyResponse") + #expect(DeliveryStatus.sending.displayText == "Sending...") + #expect(DeliveryStatus.delivered(to: "Alice", at: Date()).displayText == "Delivered to Alice") + #expect(DeliveryStatus.read(by: "Bob", at: Date()).displayText == "Read by Bob") + #expect(DeliveryStatus.failed(reason: "oops").displayText == "Failed: oops") + #expect(DeliveryStatus.partiallyDelivered(reached: 1, total: 3).displayText == "Delivered to 1/3") + #expect(delegate.isFavorite(fingerprint: "fp") == false) + + delegate.didUpdateMessageDeliveryStatus("msg-1", status: .sent) + delegate.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(), timestamp: Date()) + delegate.didReceivePublicMessage(from: peerID, nickname: "Alice", content: "hi", timestamp: Date(), messageID: "msg-1") + } + + @Test + func transportDefaults_forwardOrNoOp() { + let probe = DefaultTransportProbe() + let peerID = PeerID(str: "0123456789abcdef") + let filePacket = BitchatFilePacket( + fileName: "voice.m4a", + fileSize: 4, + mimeType: "audio/mp4", + content: Data([1, 2, 3, 4]) + ) + + probe.sendMessage("hello", mentions: ["@alice"], messageID: "msg-1", timestamp: Date()) + probe.sendVerifyChallenge(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x01])) + probe.sendVerifyResponse(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x02])) + probe.sendFileBroadcast(filePacket, transferId: "tx-1") + probe.sendFilePrivate(filePacket, to: peerID, transferId: "tx-2") + probe.cancelTransfer("tx-3") + probe.declinePendingFile(id: "pending") + + #expect(probe.sentMessages.count == 1) + #expect(probe.sentMessages.first?.content == "hello") + #expect(probe.acceptPendingFile(id: "pending") == nil) + } + + @Test + func previewMessage_exposesStableSampleShape() { + let preview = BitchatMessage.preview + + #expect(preview.sender == "John Doe") + #expect(preview.content == "Hello") + #expect(preview.deliveryStatus == .sent) + #expect(preview.isPrivate == false) + } +} diff --git a/bitchatTests/PublicTimelineStoreTests.swift b/bitchatTests/PublicTimelineStoreTests.swift new file mode 100644 index 00000000..ac177117 --- /dev/null +++ b/bitchatTests/PublicTimelineStoreTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import bitchat + +@Suite("PublicTimelineStore Tests") +struct PublicTimelineStoreTests { + + @Test("Mesh timeline deduplicates and trims to cap") + func meshTimelineDeduplicatesAndTrims() { + var store = PublicTimelineStore(meshCap: 2, geohashCap: 2) + let first = TestHelpers.createTestMessage(content: "one") + let second = TestHelpers.createTestMessage(content: "two") + let third = TestHelpers.createTestMessage(content: "three") + + store.append(first, to: .mesh) + store.append(second, to: .mesh) + store.append(first, to: .mesh) + store.append(third, to: .mesh) + + let messages = store.messages(for: .mesh) + #expect(messages.map(\.content) == ["two", "three"]) + } + + @Test("Geohash appendIfAbsent remove and clear work together") + func geohashStoreSupportsAppendRemoveAndClear() { + var store = PublicTimelineStore(meshCap: 2, geohashCap: 3) + let geohash = "u4pruydq" + let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) + let first = TestHelpers.createTestMessage(content: "geo one") + let second = TestHelpers.createTestMessage(content: "geo two") + + let didAppendFirst = store.appendIfAbsent(first, toGeohash: geohash) + let didAppendDuplicate = store.appendIfAbsent(first, toGeohash: geohash) + + #expect(didAppendFirst) + #expect(!didAppendDuplicate) + store.append(second, toGeohash: geohash) + let removed = store.removeMessage(withID: first.id) + + #expect(removed?.id == first.id) + #expect(store.messages(for: channel).map(\.content) == ["geo two"]) + + store.clear(channel: channel) + #expect(store.messages(for: channel).isEmpty) + } + + @Test("Mutate geohash updates stored messages in place") + func mutateGeohashAppliesTransformation() { + var store = PublicTimelineStore(meshCap: 2, geohashCap: 3) + let geohash = "u4pruydq" + let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) + let first = TestHelpers.createTestMessage(content: "geo one") + + store.append(first, toGeohash: geohash) + store.mutateGeohash(geohash) { timeline in + timeline.append(TestHelpers.createTestMessage(content: "geo two")) + } + + #expect(store.messages(for: channel).map(\.content) == ["geo one", "geo two"]) + } + + @Test("Queued geohash system messages drain once") + func pendingGeohashSystemMessagesDrainOnce() { + var store = PublicTimelineStore(meshCap: 1, geohashCap: 1) + + store.queueGeohashSystemMessage("first") + store.queueGeohashSystemMessage("second") + + #expect(store.drainPendingGeohashSystemMessages() == ["first", "second"]) + #expect(store.drainPendingGeohashSystemMessages().isEmpty) + } +} diff --git a/bitchatTests/ReadReceiptTests.swift b/bitchatTests/ReadReceiptTests.swift new file mode 100644 index 00000000..e6ec19bc --- /dev/null +++ b/bitchatTests/ReadReceiptTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import bitchat + +@Suite("ReadReceipt Tests") +struct ReadReceiptTests { + + @Test("JSON encode and decode round-trip stable fields") + func jsonRoundTrip() throws { + let receipt = ReadReceipt( + originalMessageID: UUID().uuidString, + readerID: PeerID(str: "0123456789abcdef"), + readerNickname: "Alice" + ) + + let encoded = try #require(receipt.encode(), "Receipt should encode to JSON") + let decoded = try #require(ReadReceipt.decode(from: encoded), "Receipt should decode from JSON") + + #expect(decoded.originalMessageID == receipt.originalMessageID) + #expect(decoded.receiptID == receipt.receiptID) + #expect(decoded.readerID == receipt.readerID) + #expect(decoded.readerNickname == receipt.readerNickname) + #expect(abs(decoded.timestamp.timeIntervalSince(receipt.timestamp)) < 0.001) + } + + @Test("Binary encode and decode round-trip stable fields") + func binaryRoundTrip() throws { + let receipt = ReadReceipt( + originalMessageID: UUID().uuidString, + readerID: PeerID(str: "fedcba9876543210"), + readerNickname: "Bob" + ) + + let decoded = try #require( + ReadReceipt.fromBinaryData(receipt.toBinaryData()), + "Receipt should decode from binary data" + ) + + #expect(decoded.originalMessageID == receipt.originalMessageID.uppercased()) + #expect(decoded.receiptID == receipt.receiptID.uppercased()) + #expect(decoded.readerID == receipt.readerID) + #expect(decoded.readerNickname == receipt.readerNickname) + } + + @Test("Binary decode rejects truncated data") + func binaryDecodeRejectsTruncatedData() { + #expect(ReadReceipt.fromBinaryData(Data()) == nil) + #expect(ReadReceipt.fromBinaryData(Data(repeating: 0, count: 48)) == nil) + } + + @Test("Binary decode rejects stale timestamps") + func binaryDecodeRejectsStaleTimestamp() { + let receipt = ReadReceipt( + originalMessageID: UUID().uuidString, + readerID: PeerID(str: "0011223344556677"), + readerNickname: "Carol" + ) + var data = receipt.toBinaryData() + + data.replaceSubrange(40..<48, with: Data(repeating: 0, count: 8)) + + #expect(ReadReceipt.fromBinaryData(data) == nil) + } +} diff --git a/bitchatTests/Services/AutocompleteServiceTests.swift b/bitchatTests/Services/AutocompleteServiceTests.swift new file mode 100644 index 00000000..f12426bf --- /dev/null +++ b/bitchatTests/Services/AutocompleteServiceTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import bitchat + +@Suite("AutocompleteService Tests") +struct AutocompleteServiceTests { + + @Test("Mention suggestions are sorted, capped, and include replacement range") + func mentionSuggestionsAreSortedAndCapped() { + let service = AutocompleteService() + let text = "hi @al" + + let result = service.getSuggestions( + for: text, + peers: ["zoe", "alice", "albert", "bob", "alex", "ally", "alpha"], + cursorPosition: text.count + ) + + #expect(result.suggestions == ["@albert", "@alex", "@alice", "@ally", "@alpha"]) + #expect(result.range == NSRange(location: 3, length: 3)) + } + + @Test("Suggestions are empty when cursor is not at a trailing mention") + func suggestionsRequireTrailingMentionContext() { + let service = AutocompleteService() + let text = "hi @al there" + + let result = service.getSuggestions( + for: text, + peers: ["alice", "albert"], + cursorPosition: text.count + ) + + #expect(result.suggestions.isEmpty) + #expect(result.range == nil) + } + + @Test("Applying suggestions replaces the range and adds command spacing only when needed") + func applySuggestionReplacesRangeAndHandlesCommandSpacing() { + let service = AutocompleteService() + + let mentionResult = service.applySuggestion("@alice", to: "hi @al", range: NSRange(location: 3, length: 3)) + let msgCommand = service.applySuggestion("/msg", to: "/m", range: NSRange(location: 0, length: 2)) + let clearCommand = service.applySuggestion("/clear", to: "/c", range: NSRange(location: 0, length: 2)) + + #expect(mentionResult == "hi @alice") + #expect(msgCommand == "/msg ") + #expect(clearCommand == "/clear") + } +} diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift new file mode 100644 index 00000000..29f2d9d0 --- /dev/null +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -0,0 +1,231 @@ +import Foundation +import Testing +@testable import bitchat + +@Suite("NoiseEncryptionService Tests") +struct NoiseEncryptionServiceTests { + + @Test("Encryption status accessors cover all cases") + func encryptionStatusAccessorsCoverAllCases() { + #expect(EncryptionStatus.none.icon == "lock.slash") + #expect(EncryptionStatus.noHandshake.icon == nil) + #expect(EncryptionStatus.noiseHandshaking.icon == "lock.rotation") + #expect(EncryptionStatus.noiseSecured.icon == "lock.fill") + #expect(EncryptionStatus.noiseVerified.icon == "checkmark.seal.fill") + + #expect(!EncryptionStatus.none.description.isEmpty) + #expect(!EncryptionStatus.noHandshake.description.isEmpty) + #expect(!EncryptionStatus.noiseHandshaking.description.isEmpty) + #expect(!EncryptionStatus.noiseSecured.description.isEmpty) + #expect(!EncryptionStatus.noiseVerified.description.isEmpty) + + #expect(!EncryptionStatus.none.accessibilityDescription.isEmpty) + #expect(!EncryptionStatus.noHandshake.accessibilityDescription.isEmpty) + #expect(!EncryptionStatus.noiseHandshaking.accessibilityDescription.isEmpty) + #expect(!EncryptionStatus.noiseSecured.accessibilityDescription.isEmpty) + #expect(!EncryptionStatus.noiseVerified.accessibilityDescription.isEmpty) + } + + @Test("Announce and packet signatures round-trip and detect tampering") + func announceAndPacketSignaturesRoundTrip() throws { + let service = NoiseEncryptionService(keychain: MockKeychain()) + let signingPublicKey = service.getSigningPublicKeyData() + let noisePublicKey = service.getStaticPublicKeyData() + + let signature = try #require( + service.buildAnnounceSignature( + peerID: Data([0xAA, 0xBB]), + noiseKey: noisePublicKey, + ed25519Key: signingPublicKey, + nickname: "Alice", + timestampMs: 12345 + ), + "Expected announce signature" + ) + + #expect( + service.verifyAnnounceSignature( + signature: signature, + peerID: Data([0xAA, 0xBB]), + noiseKey: noisePublicKey, + ed25519Key: signingPublicKey, + nickname: "Alice", + timestampMs: 12345, + publicKey: signingPublicKey + ) + ) + #expect( + !service.verifyAnnounceSignature( + signature: signature, + peerID: Data([0xAA, 0xBB]), + noiseKey: noisePublicKey, + ed25519Key: signingPublicKey, + nickname: "Mallory", + timestampMs: 12345, + publicKey: signingPublicKey + ) + ) + #expect(!service.verifySignature(signature, for: Data("data".utf8), publicKey: Data([1, 2, 3]))) + + let packet = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data([0, 1, 2, 3, 4, 5, 6, 7]), + recipientID: nil, + timestamp: 42, + payload: Data("payload".utf8), + signature: nil, + ttl: 7 + ) + let signedPacket = try #require(service.signPacket(packet), "Expected signed packet") + + #expect(service.verifyPacketSignature(signedPacket, publicKey: signingPublicKey)) + #expect(!service.verifyPacketSignature(packet, publicKey: signingPublicKey)) + + var tampered = signedPacket + tampered.signature = Data(repeating: 0xFF, count: 64) + #expect(!service.verifyPacketSignature(tampered, publicKey: signingPublicKey)) + } + + @Test("Service-level handshake, encryption, and fingerprint lifecycle work") + func handshakeEncryptionAndFingerprintLifecycle() async throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(str: "0011223344556677") + let bobPeerID = PeerID(str: "8899aabbccddeeff") + let recorder = AuthenticationRecorder() + + #expect(alice.onPeerAuthenticated == nil) + alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) + bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:) + + try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID) + + let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 0.5) + #expect(authenticated) + #expect(alice.hasEstablishedSession(with: alicePeerID)) + #expect(bob.hasEstablishedSession(with: bobPeerID)) + #expect(alice.hasSession(with: alicePeerID)) + #expect(bob.hasSession(with: bobPeerID)) + #expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32) + #expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32) + #expect(alice.getPeerFingerprint(alicePeerID) != nil) + #expect(bob.getPeerFingerprint(bobPeerID) != nil) + + let plaintext = Data("secret payload".utf8) + let ciphertext = try alice.encrypt(plaintext, for: alicePeerID) + let decrypted = try bob.decrypt(ciphertext, from: bobPeerID) + #expect(decrypted == plaintext) + + alice.clearSession(for: alicePeerID) + #expect(!alice.hasSession(with: alicePeerID)) + #expect(alice.getPeerFingerprint(alicePeerID) == nil) + + bob.clearEphemeralStateForPanic() + #expect(!bob.hasSession(with: bobPeerID)) + #expect(bob.getPeerFingerprint(bobPeerID) == nil) + } + + @Test("Encrypt without a session requests handshake and decrypt without session fails") + func handshakeRequiredAndSessionNotEstablishedErrors() throws { + let service = NoiseEncryptionService(keychain: MockKeychain()) + let peerID = PeerID(str: "1021324354657687") + var requestedPeerID: PeerID? + + service.onHandshakeRequired = { requestedPeerID = $0 } + + do { + _ = try service.encrypt(Data("hello".utf8), for: peerID) + Issue.record("Expected handshakeRequired error") + } catch NoiseEncryptionError.handshakeRequired { + #expect(requestedPeerID == peerID) + } catch { + Issue.record("Unexpected error: \(error)") + } + + do { + _ = try service.decrypt(Data("hello".utf8), from: peerID) + Issue.record("Expected sessionNotEstablished error") + } catch NoiseEncryptionError.sessionNotEstablished { + // Expected + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("Clearing persistent identity removes saved keys") + func clearPersistentIdentityRemovesSavedKeys() { + let keychain = MockKeychain() + let service = NoiseEncryptionService(keychain: keychain) + + #expect(service.getStaticPublicKeyData().count == 32) + #expect(service.getSigningPublicKeyData().count == 32) + + service.clearPersistentIdentity() + + if case .itemNotFound = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") { + } else { + Issue.record("Expected noiseStaticKey to be removed") + } + + if case .itemNotFound = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey") { + } else { + Issue.record("Expected ed25519SigningKey to be removed") + } + } + + @Test("NoiseMessage JSON and binary encoding round-trip") + func noiseMessageRoundTrips() throws { + let message = NoiseMessage( + type: .encryptedMessage, + sessionID: UUID().uuidString, + payload: Data([1, 2, 3, 4]) + ) + + let encoded = try #require(message.encode(), "Expected JSON encoding") + let decoded = try #require(NoiseMessage.decode(from: encoded), "Expected JSON decode") + #expect(decoded.type == message.type) + #expect(decoded.sessionID == message.sessionID) + #expect(decoded.payload == message.payload) + + #expect(NoiseMessage.decodeWithError(from: Data("bad".utf8)) == nil) + + let binary = message.toBinaryData() + let roundTripped = try #require(NoiseMessage.fromBinaryData(binary), "Expected binary decode") + #expect(roundTripped.type == message.type) + #expect(roundTripped.sessionID == message.sessionID) + #expect(roundTripped.payload == message.payload) + #expect(NoiseMessage.fromBinaryData(Data()) == nil) + } + + private func establishSessions( + alice: NoiseEncryptionService, + bob: NoiseEncryptionService, + alicePeerID: PeerID, + bobPeerID: PeerID + ) throws { + let message1 = try alice.initiateHandshake(with: alicePeerID) + let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1) + let message2 = try #require(response, "Expected handshake response") + let final = try alice.processHandshakeMessage(from: alicePeerID, message: message2) + let message3 = try #require(final, "Expected handshake final") + let finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3) + #expect(finalMessage == nil) + } +} + +private final class AuthenticationRecorder: @unchecked Sendable { + private let lock = NSLock() + private var entries: [(PeerID, String)] = [] + + var count: Int { + lock.lock() + defer { lock.unlock() } + return entries.count + } + + func record(peerID: PeerID, fingerprint: String) { + lock.lock() + entries.append((peerID, fingerprint)) + lock.unlock() + } +} diff --git a/bitchatTests/Services/NostrTransportTests.swift b/bitchatTests/Services/NostrTransportTests.swift index ce92228d..4f1475a1 100644 --- a/bitchatTests/Services/NostrTransportTests.swift +++ b/bitchatTests/Services/NostrTransportTests.swift @@ -10,8 +10,303 @@ import Foundation import Testing @testable import bitchat -@Suite("NostrTransport Thread Safety Tests") +@Suite("NostrTransport Tests") struct NostrTransportTests { + typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship + + @Test("Warm cache marks full and short IDs reachable") + @MainActor + func reachabilityCacheWarmsFromFavorites() async throws { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: keychain) + let recipient = try NostrIdentity.generate() + let noiseKey = Data((0..<32).map(UInt8.init)) + let fullPeerID = PeerID(hexData: noiseKey) + let shortPeerID = fullPeerID.toShort() + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Alice" + ) + let favorites = [noiseKey: relationship] + + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + dependencies: makeDependencies( + loadFavorites: { favorites }, + favoriteStatusForNoiseKey: { favorites[$0] }, + favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil }, + currentIdentity: { nil } + ) + ) + + #expect(!transport.isPeerReachable(fullPeerID)) + #expect(transport.isPeerReachable(shortPeerID)) + #expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed"))) + } + + @Test("Favorite status notification refreshes reachability cache") + @MainActor + func favoriteStatusNotificationRefreshesReachability() async throws { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: keychain) + let recipient = try NostrIdentity.generate() + let noiseKey = Data((32..<64).map(UInt8.init)) + let peerID = PeerID(hexData: noiseKey).toShort() + let notificationCenter = NotificationCenter() + var favorites: [Data: FavoriteRelationship] = [:] + + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + dependencies: makeDependencies( + notificationCenter: notificationCenter, + loadFavorites: { favorites }, + favoriteStatusForNoiseKey: { favorites[$0] }, + favoriteStatusForPeerID: { _ in favorites.values.first }, + currentIdentity: { nil } + ) + ) + + #expect(!transport.isPeerReachable(peerID)) + + favorites[noiseKey] = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Bob" + ) + notificationCenter.post(name: .favoriteStatusChanged, object: nil) + + let didRefresh = await TestHelpers.waitUntil({ transport.isPeerReachable(peerID) }, timeout: 0.5) + #expect(didRefresh) + } + + @Test("Private message resolves short peer ID and emits decryptable packet") + @MainActor + func sendPrivateMessageResolvesShortPeerID() async throws { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: keychain) + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let noiseKey = Data((64..<96).map(UInt8.init)) + let shortPeerID = PeerID(hexData: noiseKey).toShort() + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Carol" + ) + let probe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { _ in nil }, + favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil }, + currentIdentity: { sender }, + registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), + sendEvent: probe.record(event:), + scheduleAfter: { delay, action in + probe.enqueueScheduledAction(delay: delay, action: action) + } + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + + transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1") + + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5) + #expect(didSend) + let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) + let privateMessage = try decodePrivateMessage(from: result.payload) + + #expect(result.senderPubkey == sender.publicKeyHex) + #expect(privateMessage.messageID == "pm-1") + #expect(privateMessage.content == "hello over nostr") + #expect(result.packet.recipientID == shortPeerID.routingData) + #expect(probe.pendingGiftWrapIDs.isEmpty) + } + + @Test("Favorite notification embeds current npub") + @MainActor + func sendFavoriteNotificationEmbedsCurrentIdentity() async throws { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: keychain) + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let noiseKey = Data((96..<128).map(UInt8.init)) + let fullPeerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Dan" + ) + let probe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil }, + favoriteStatusForPeerID: { _ in nil }, + currentIdentity: { sender }, + registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), + sendEvent: probe.record(event:), + scheduleAfter: { delay, action in + probe.enqueueScheduledAction(delay: delay, action: action) + } + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + + transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true) + + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5) + #expect(didSend) + let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) + let privateMessage = try decodePrivateMessage(from: result.payload) + + #expect(privateMessage.content == "[FAVORITED]:\(sender.npub)") + } + + @Test("Delivery ACK encodes delivered payload type") + @MainActor + func sendDeliveryAckEmitsDeliveredAck() async throws { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: keychain) + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let noiseKey = Data((128..<160).map(UInt8.init)) + let fullPeerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Eve" + ) + let probe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil }, + favoriteStatusForPeerID: { _ in nil }, + currentIdentity: { sender }, + registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), + sendEvent: probe.record(event:), + scheduleAfter: { delay, action in + probe.enqueueScheduledAction(delay: delay, action: action) + } + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + + transport.sendDeliveryAck(for: "ack-1", to: fullPeerID) + + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5) + #expect(didSend) + let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) + + #expect(result.payload.type == .delivered) + #expect(String(data: result.payload.data, encoding: .utf8) == "ack-1") + #expect(result.packet.recipientID == fullPeerID.toShort().routingData) + } + + @Test("Geohash private message registers pending gift wrap") + @MainActor + func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: keychain) + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let probe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + dependencies: makeDependencies( + currentIdentity: { sender }, + registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), + sendEvent: probe.record(event:), + scheduleAfter: { delay, action in + probe.enqueueScheduledAction(delay: delay, action: action) + } + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + + transport.sendPrivateMessageGeohash( + content: "geo hello", + toRecipientHex: recipient.publicKeyHex, + from: sender, + messageID: "geo-1" + ) + + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5) + #expect(didSend) + let event = probe.sentEvents[0] + let result = try decodeEmbeddedPayload(from: event, recipient: recipient) + let privateMessage = try decodePrivateMessage(from: result.payload) + + #expect(privateMessage.messageID == "geo-1") + #expect(privateMessage.content == "geo hello") + #expect(result.packet.recipientID == nil) + #expect(probe.pendingGiftWrapIDs == [event.id]) + } + + @Test("Read receipt queue sends in order and waits for scheduler") + @MainActor + func readReceiptQueueThrottlesSequentially() async throws { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: keychain) + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let noiseKey = Data((160..<192).map(UInt8.init)) + let fullPeerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Frank" + ) + let probe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil }, + favoriteStatusForPeerID: { _ in nil }, + currentIdentity: { sender }, + registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), + sendEvent: probe.record(event:), + scheduleAfter: { delay, action in + probe.enqueueScheduledAction(delay: delay, action: action) + } + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + + let first = ReadReceipt(originalMessageID: "read-1", readerID: transport.myPeerID, readerNickname: "Me") + let second = ReadReceipt(originalMessageID: "read-2", readerID: transport.myPeerID, readerNickname: "Me") + + transport.sendReadReceipt(first, to: fullPeerID) + transport.sendReadReceipt(second, to: fullPeerID) + + let sentFirst = await TestHelpers.waitUntil( + { probe.sentEvents.count == 1 && probe.scheduledActionCount == 1 }, + timeout: 0.5 + ) + try #require(sentFirst, "Expected first read receipt and throttle action to be queued") + let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt event") + let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload + #expect(firstPayload.type == .readReceipt) + #expect(String(data: firstPayload.data, encoding: .utf8) == "read-1") + + try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt") + + let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 0.5) + try #require(sentSecond, "Expected second read receipt after running throttle action") + let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event") + let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload + #expect(secondPayload.type == .readReceipt) + #expect(String(data: secondPayload.data, encoding: .utf8) == "read-2") + } @Test("Concurrent read receipt enqueue does not crash") @MainActor @@ -19,8 +314,6 @@ struct NostrTransportTests { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) let transport = NostrTransport(keychain: keychain, idBridge: idBridge) - - // Create 100 concurrent read receipt submissions let iterations = 100 await withTaskGroup(of: Void.self) { group in @@ -36,49 +329,6 @@ struct NostrTransportTests { } } } - - // If we reach here without crashing, the test passes - // The concurrent enqueue operations completed without data races - } - - @Test("Read queue processes under concurrent load") - @MainActor - func readQueueProcessingUnderLoad() async throws { - let keychain = MockKeychain() - let idBridge = NostrIdentityBridge(keychain: keychain) - let transport = NostrTransport(keychain: keychain, idBridge: idBridge) - - // Rapidly enqueue many receipts from multiple concurrent sources - let iterations = 50 - - // First batch - rapid fire - for i in 0.. [Data: FavoriteRelationship] = { [:] }, + favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil }, + favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil }, + currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil }, + registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in }, + sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in }, + scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in } + ) -> NostrTransport.Dependencies { + NostrTransport.Dependencies( + notificationCenter: notificationCenter, + loadFavorites: loadFavorites, + favoriteStatusForNoiseKey: favoriteStatusForNoiseKey, + favoriteStatusForPeerID: favoriteStatusForPeerID, + currentIdentity: currentIdentity, + registerPendingGiftWrap: registerPendingGiftWrap, + sendEvent: sendEvent, + scheduleAfter: scheduleAfter + ) + } + + private func makeRelationship( + peerNoisePublicKey: Data, + peerNostrPublicKey: String?, + peerNickname: String + ) -> FavoriteRelationship { + FavoriteRelationship( + peerNoisePublicKey: peerNoisePublicKey, + peerNostrPublicKey: peerNostrPublicKey, + peerNickname: peerNickname, + isFavorite: true, + theyFavoritedUs: true, + favoritedAt: Date(timeIntervalSince1970: 1), + lastUpdated: Date(timeIntervalSince1970: 2) + ) + } + + private func decodeEmbeddedPayload( + from event: NostrEvent, + recipient: NostrIdentity + ) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) { + let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( + giftWrap: event, + recipientIdentity: recipient + ) + guard content.hasPrefix("bitchat1:") else { + throw NostrTransportTestError.invalidEmbeddedContent + } + let encoded = String(content.dropFirst("bitchat1:".count)) + guard let packetData = base64URLDecode(encoded), + let packet = BitchatPacket.from(packetData), + let payload = NoisePayload.decode(packet.payload) else { + throw NostrTransportTestError.invalidPacket + } + return (packet, payload, senderPubkey) + } + + private func decodePrivateMessage(from payload: NoisePayload) throws -> PrivateMessagePacket { + guard payload.type == .privateMessage, + let message = PrivateMessagePacket.decode(from: payload.data) else { + throw NostrTransportTestError.invalidPrivateMessage + } + return message + } +} + +private enum NostrTransportTestError: Error { + case invalidEmbeddedContent + case invalidPacket + case invalidPrivateMessage +} + +private func base64URLDecode(_ string: String) -> Data? { + var candidate = string + let padding = (4 - (candidate.count % 4)) % 4 + if padding > 0 { + candidate += String(repeating: "=", count: padding) + } + candidate = candidate + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + return Data(base64Encoded: candidate) +} + +private final class NostrTransportProbe: @unchecked Sendable { + private let lock = NSLock() + private var sentEventsStorage: [NostrEvent] = [] + private var pendingGiftWrapIDsStorage: [String] = [] + private var scheduledActionsStorage: [(@Sendable () -> Void)] = [] + + var sentEvents: [NostrEvent] { + lock.lock() + defer { lock.unlock() } + return sentEventsStorage + } + + var pendingGiftWrapIDs: [String] { + lock.lock() + defer { lock.unlock() } + return pendingGiftWrapIDsStorage + } + + var scheduledActionCount: Int { + lock.lock() + defer { lock.unlock() } + return scheduledActionsStorage.count + } + + func record(event: NostrEvent) { + lock.lock() + sentEventsStorage.append(event) + lock.unlock() + } + + func recordPendingGiftWrap(id: String) { + lock.lock() + pendingGiftWrapIDsStorage.append(id) + lock.unlock() + } + + func enqueueScheduledAction(delay: TimeInterval, action: @escaping @Sendable () -> Void) { + _ = delay + lock.lock() + scheduledActionsStorage.append(action) + lock.unlock() + } + + @discardableResult + func runNextScheduledAction() -> Bool { + let action: (@Sendable () -> Void)? + lock.lock() + action = scheduledActionsStorage.isEmpty ? nil : scheduledActionsStorage.removeFirst() + lock.unlock() + guard let action else { return false } + action() + return true + } } diff --git a/bitchatTests/Services/PrivateChatManagerTests.swift b/bitchatTests/Services/PrivateChatManagerTests.swift index ecdba5a3..4a7b8c1d 100644 --- a/bitchatTests/Services/PrivateChatManagerTests.swift +++ b/bitchatTests/Services/PrivateChatManagerTests.swift @@ -69,4 +69,202 @@ struct PrivateChatManagerTests { #expect(manager.sentReadReceipts.contains("pm-2")) #expect(!manager.unreadMessages.contains(peerID)) } + + @Test @MainActor + func markAsRead_withoutRouterFallsBackToTransport() async { + let transport = MockTransport() + let manager = PrivateChatManager(meshService: transport) + let peerID = PeerID(str: "00000000000000CC") + + manager.privateChats[peerID] = [ + BitchatMessage( + id: "pm-fallback", + sender: "Peer", + content: "Hi", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerID + ) + ] + + manager.markAsRead(from: peerID) + + #expect(transport.sentReadReceipts.count == 1) + #expect(transport.sentReadReceipts.first?.receipt.originalMessageID == "pm-fallback") + } + + @Test @MainActor + func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async { + let transport = MockTransport() + let manager = PrivateChatManager(meshService: transport) + let identityManager = MockIdentityManager(MockKeychain()) + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let unifiedPeerService = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identityManager) + manager.unifiedPeerService = unifiedPeerService + + let peerID = PeerID(str: "0123456789abcdef") + let noiseKey = Data((0..<32).map(UInt8.init)) + let stablePeerID = PeerID(hexData: noiseKey) + + transport.updatePeerSnapshots([ + TransportPeerSnapshot( + peerID: peerID, + nickname: "Alice", + isConnected: true, + noisePublicKey: noiseKey, + lastSeen: Date() + ) + ]) + try? await Task.sleep(nanoseconds: 50_000_000) + + manager.privateChats[stablePeerID] = [ + BitchatMessage( + id: "stable-msg", + sender: "Alice", + content: "Hello from stable", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: stablePeerID + ) + ] + manager.unreadMessages.insert(stablePeerID) + + let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "Alice", persistedReadReceipts: []) + + #expect(hadUnread) + #expect(manager.privateChats[stablePeerID] == nil) + #expect(manager.privateChats[peerID]?.count == 1) + #expect(manager.privateChats[peerID]?.first?.senderPeerID == peerID) + #expect(manager.unreadMessages.contains(peerID)) + } + + @Test @MainActor + func consolidateMessages_movesTemporaryGeoDMHistoryByNickname() async { + let transport = MockTransport() + let manager = PrivateChatManager(meshService: transport) + let peerID = PeerID(str: "0011223344556677") + let tempPeerID = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000042") + + manager.privateChats[tempPeerID] = [ + BitchatMessage( + id: "geo-msg", + sender: "Alice", + content: "Geo hello", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: tempPeerID + ) + ] + manager.unreadMessages.insert(tempPeerID) + + let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "alice", persistedReadReceipts: []) + + #expect(hadUnread) + #expect(manager.privateChats[tempPeerID] == nil) + #expect(manager.privateChats[peerID]?.count == 1) + #expect(manager.privateChats[peerID]?.first?.senderPeerID == peerID) + #expect(manager.unreadMessages.contains(peerID)) + #expect(!manager.unreadMessages.contains(tempPeerID)) + } + + @Test @MainActor + func syncReadReceiptsForSentMessages_onlyCopiesDeliveredAndRead() async { + let transport = MockTransport() + let manager = PrivateChatManager(meshService: transport) + let peerID = PeerID(str: "00000000000000DD") + + manager.privateChats[peerID] = [ + BitchatMessage( + id: "sent-read", + sender: "Me", + content: "One", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .read(by: "Peer", at: Date()) + ), + BitchatMessage( + id: "sent-delivered", + sender: "Me", + content: "Two", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .delivered(to: "Peer", at: Date()) + ), + BitchatMessage( + id: "sent-failed", + sender: "Me", + content: "Three", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .failed(reason: "nope") + ) + ] + + var externalReceipts = Set() + manager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: "Me", externalReceipts: &externalReceipts) + + #expect(externalReceipts == Set(["sent-read", "sent-delivered"])) + #expect(manager.sentReadReceipts == Set(["sent-read", "sent-delivered"])) + } + + @Test @MainActor + func sanitizeChat_sortsChronologicallyAndKeepsLatestDuplicate() async { + let transport = MockTransport() + let manager = PrivateChatManager(meshService: transport) + let peerID = PeerID(str: "00000000000000EE") + let base = Date(timeIntervalSince1970: 10) + + manager.privateChats[peerID] = [ + BitchatMessage( + id: "same", + sender: "Peer", + content: "Older", + timestamp: base.addingTimeInterval(10), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerID + ), + BitchatMessage( + id: "first", + sender: "Peer", + content: "First", + timestamp: base, + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerID + ), + BitchatMessage( + id: "same", + sender: "Peer", + content: "Newest", + timestamp: base.addingTimeInterval(20), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerID + ) + ] + + manager.sanitizeChat(for: peerID) + + #expect(manager.privateChats[peerID]?.map(\.id) == ["first", "same"]) + #expect(manager.privateChats[peerID]?.last?.content == "Newest") + } } diff --git a/bitchatTests/Services/TransferProgressManagerTests.swift b/bitchatTests/Services/TransferProgressManagerTests.swift new file mode 100644 index 00000000..9ed6374a --- /dev/null +++ b/bitchatTests/Services/TransferProgressManagerTests.swift @@ -0,0 +1,125 @@ +import Foundation +import Combine +import Testing +@testable import bitchat + +@Suite("TransferProgressManager Tests") +struct TransferProgressManagerTests { + + @Test("Start publishes started event and stores snapshot") + @MainActor + func startPublishesAndStoresSnapshot() async throws { + let manager = TransferProgressManager() + let transferID = "transfer-start" + var cancellable: AnyCancellable? + let recorder = EventRecorder() + + cancellable = manager.publisher.sink { event in + if case .started(let id, let total) = event { + recorder.append("started:\(id):\(total)") + } + } + + manager.start(id: transferID, totalFragments: 3) + + let didReceive = await TestHelpers.waitUntil({ + recorder.values == ["started:\(transferID):3"] + }, timeout: 0.5) + #expect(didReceive) + + #expect(recorder.values == ["started:\(transferID):3"]) + #expect(manager.snapshot(id: transferID)?.sent == 0) + #expect(manager.snapshot(id: transferID)?.total == 3) + _ = cancellable + } + + @Test("Sending final fragment publishes update and completion then clears snapshot") + @MainActor + func recordFragmentSentPublishesProgressAndCompletion() async throws { + let manager = TransferProgressManager() + let transferID = "transfer-complete" + var cancellable: AnyCancellable? + let recorder = EventRecorder() + + cancellable = manager.publisher.sink { event in + switch event { + case .started(let id, let total): + recorder.append("started:\(id):\(total)") + case .updated(let id, let sent, let total): + recorder.append("updated:\(id):\(sent):\(total)") + case .completed(let id, let total): + recorder.append("completed:\(id):\(total)") + case .cancelled: + break + } + } + + manager.start(id: transferID, totalFragments: 1) + manager.recordFragmentSent(id: transferID) + + let didReceive = await TestHelpers.waitUntil({ + recorder.values.count == 3 + }, timeout: 0.5) + #expect(didReceive) + + #expect(recorder.values == [ + "started:\(transferID):1", + "updated:\(transferID):1:1", + "completed:\(transferID):1" + ]) + #expect(manager.snapshot(id: transferID) == nil) + _ = cancellable + } + + @Test("Cancel publishes cancelled event and clears state") + @MainActor + func cancelPublishesAndClearsState() async throws { + let manager = TransferProgressManager() + let transferID = "transfer-cancel" + var cancellable: AnyCancellable? + let recorder = EventRecorder() + + cancellable = manager.publisher.sink { event in + switch event { + case .started(let id, let total): + recorder.append("started:\(id):\(total)") + case .cancelled(let id, let sent, let total): + recorder.append("cancelled:\(id):\(sent):\(total)") + case .updated, .completed: + break + } + } + + manager.start(id: transferID, totalFragments: 4) + manager.recordFragmentSent(id: transferID) + manager.cancel(id: transferID) + + let didReceive = await TestHelpers.waitUntil({ + recorder.values.contains("started:\(transferID):4") && + recorder.values.contains("cancelled:\(transferID):1:4") + }, timeout: 0.5) + #expect(didReceive) + + #expect(recorder.values.contains("started:\(transferID):4")) + #expect(recorder.values.contains("cancelled:\(transferID):1:4")) + #expect(manager.snapshot(id: transferID) == nil) + _ = cancellable + } +} + +private final class EventRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + + var values: [String] { + lock.lock() + defer { lock.unlock() } + return storage + } + + func append(_ value: String) { + lock.lock() + storage.append(value) + lock.unlock() + } +} diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift new file mode 100644 index 00000000..262db7ef --- /dev/null +++ b/bitchatTests/ViewSmokeTests.swift @@ -0,0 +1,462 @@ +import Testing +import Foundation +import SwiftUI +import CoreGraphics +import AVFoundation +#if os(iOS) +import UIKit +#else +import AppKit +#endif +@testable import bitchat + +@MainActor +private func makeSmokeViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identityManager: MockIdentityManager) { + let keychain = MockKeychain() + let keychainHelper = MockKeychainHelper() + let idBridge = NostrIdentityBridge(keychain: keychainHelper) + let identityManager = MockIdentityManager(keychain) + let transport = MockTransport() + + let viewModel = ChatViewModel( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + transport: transport + ) + + return (viewModel, transport, identityManager) +} + +@MainActor +@discardableResult +private func mount(_ view: V) -> AnyObject { + #if os(iOS) + let host = UIHostingController(rootView: view) + _ = host.view + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + return host + #else + let host = NSHostingView(rootView: view) + host.layoutSubtreeIfNeeded() + _ = host.fittingSize + return host + #endif +} + +private func makeSnapshot( + peerID: PeerID, + nickname: String, + connected: Bool = true, + noiseByte: UInt8 +) -> TransportPeerSnapshot { + TransportPeerSnapshot( + peerID: peerID, + nickname: nickname, + isConnected: connected, + noisePublicKey: Data(repeating: noiseByte, count: 32), + lastSeen: Date() + ) +} + +private func makeCGImage() throws -> CGImage { + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() + let context = try #require( + CGContext( + data: nil, + width: 8, + height: 8, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + ) + context.setFillColor(CGColor(red: 0.1, green: 0.7, blue: 0.2, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: 8, height: 8)) + return try #require(context.makeImage()) +} + +private func makeTemporaryAudioURL() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("caf") + let format = try #require(AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1)) + let frameCount: AVAudioFrameCount = 1_600 + let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount)) + buffer.frameLength = frameCount + let channel = try #require(buffer.floatChannelData?[0]) + for index in 0.. URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("png") + let image = try makeCGImage() + #if os(iOS) + let data = try #require(UIImage(cgImage: image).pngData()) + #else + let rep = NSBitmapImageRep(cgImage: image) + let data = try #require(rep.representation(using: .png, properties: [:])) + #endif + try data.write(to: url) + return url +} + +@MainActor +struct ViewSmokeTests { + @Test + func fingerprintView_renders_verifiedAndPendingStates() async { + let (viewModel, transport, _) = makeSmokeViewModel() + let verifiedPeer = PeerID(str: "0102030405060708") + let pendingPeer = PeerID(str: "1112131415161718") + let verifiedFingerprint = String(repeating: "ab", count: 32) + + transport.peerFingerprints[verifiedPeer] = verifiedFingerprint + transport.peerFingerprints[pendingPeer] = nil + transport.updatePeerSnapshots([ + makeSnapshot(peerID: verifiedPeer, nickname: "Alice", noiseByte: 0x11), + makeSnapshot(peerID: pendingPeer, nickname: "Bob", noiseByte: 0x22) + ]) + try? await Task.sleep(nanoseconds: 50_000_000) + + viewModel.verifiedFingerprints.insert(verifiedFingerprint) + + let verifiedView = FingerprintView(viewModel: viewModel, peerID: verifiedPeer) + let pendingView = FingerprintView(viewModel: viewModel, peerID: pendingPeer) + + _ = verifiedView.body + _ = pendingView.body + _ = mount(verifiedView) + _ = mount(pendingView) + + #expect(viewModel.verifiedFingerprints.contains(verifiedFingerprint)) + } + + @Test + func verificationViews_renderCoreBranches() throws { + let (viewModel, transport, _) = makeSmokeViewModel() + let peerID = PeerID(str: "2122232425262728") + let fingerprint = String(repeating: "cd", count: 32) + var isPresented = true + + transport.peerFingerprints[peerID] = fingerprint + transport.updatePeerSnapshots([makeSnapshot(peerID: peerID, nickname: "Verifier", noiseByte: 0x33)]) + viewModel.selectedPrivateChatPeer = peerID + viewModel.verifiedFingerprints.insert(fingerprint) + + let image = try makeCGImage() + + let myQR = MyQRView(qrString: "bitchat://verify?name=alice&npub=npub1test") + let qrCode = QRCodeImage(data: "bitchat://verify?hello=world", size: 96) + let imageWrapper = ImageWrapper(image: image) + + _ = myQR.body + _ = qrCode.body + _ = imageWrapper.body + _ = mount(myQR) + _ = mount(qrCode) + _ = mount(imageWrapper) + _ = mount( + VerificationSheetView( + isPresented: Binding( + get: { isPresented }, + set: { isPresented = $0 } + ) + ) + .environmentObject(viewModel) + ) + } + + @Test + func meshPeerList_renders_emptyAndPopulatedStates() async { + let (viewModel, transport, identityManager) = makeSmokeViewModel() + let connectedPeer = PeerID(str: "3132333435363738") + let blockedPeer = PeerID(str: "4142434445464748") + let blockedFingerprint = String(repeating: "ef", count: 32) + + _ = mount( + MeshPeerList( + viewModel: viewModel, + textColor: .green, + secondaryTextColor: .gray, + onTapPeer: { _ in }, + onToggleFavorite: { _ in }, + onShowFingerprint: { _ in } + ) + ) + _ = MeshPeerList( + viewModel: viewModel, + textColor: .green, + secondaryTextColor: .gray, + onTapPeer: { _ in }, + onToggleFavorite: { _ in }, + onShowFingerprint: { _ in } + ).body + + transport.peerFingerprints[blockedPeer] = blockedFingerprint + identityManager.setBlocked(blockedFingerprint, isBlocked: true) + transport.updatePeerSnapshots([ + makeSnapshot(peerID: connectedPeer, nickname: "Alice", noiseByte: 0x44), + makeSnapshot(peerID: blockedPeer, nickname: "Mallory", noiseByte: 0x55) + ]) + try? await Task.sleep(nanoseconds: 50_000_000) + viewModel.unreadPrivateMessages.insert(blockedPeer) + + _ = mount( + MeshPeerList( + viewModel: viewModel, + textColor: .green, + secondaryTextColor: .gray, + onTapPeer: { _ in }, + onToggleFavorite: { _ in }, + onShowFingerprint: { _ in } + ) + ) + + #expect(viewModel.hasUnreadMessages(for: blockedPeer)) + } + + @Test + func commandSuggestionsAndLocationViews_render() { + let (viewModel, _, _) = makeSmokeViewModel() + let channel = GeohashChannel(level: .city, geohash: "u4pruy") + var messageText = "/f" + + LocationChannelManager.shared.select(.location(channel)) + + _ = mount( + CommandSuggestionsView( + messageText: Binding( + get: { messageText }, + set: { messageText = $0 } + ), + textColor: .green, + backgroundColor: .black, + secondaryTextColor: .gray + ) + .environmentObject(viewModel) + ) + + _ = mount( + LocationChannelsSheet(isPresented: .constant(true)) + .environmentObject(viewModel) + ) + + #expect(messageText == "/f") + LocationChannelManager.shared.select(.mesh) + LocationChannelManager.shared.endLiveRefresh() + } + + @Test + func locationNotesView_rendersNoRelayAndLoadedStates() throws { + let (viewModel, _, _) = makeSmokeViewModel() + + let noRelayManager = LocationNotesManager( + geohash: "u4pruydq", + dependencies: LocationNotesDependencies( + relayLookup: { _, _ in [] }, + subscribe: { _, _, _, _, _ in }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in try NostrIdentity.generate() }, + now: { Date() } + ) + ) + + var noteHandler: ((NostrEvent) -> Void)? + var eose: (() -> Void)? + let loadedManager = LocationNotesManager( + geohash: "u4pruydq", + dependencies: LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, handler, onEOSE in + noteHandler = handler + eose = onEOSE + }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in try NostrIdentity.generate() }, + now: { Date() } + ) + ) + + let identity = try NostrIdentity.generate() + let event = try NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .textNote, + tags: [["g", "u4pruydq"], ["n", "Builder"]], + content: "hello from a note" + ).sign(with: identity.schnorrSigningKey()) + noteHandler?(event) + eose?() + + _ = mount( + LocationNotesView(geohash: "u4pruydq", manager: noRelayManager) + .environmentObject(viewModel) + ) + _ = mount( + LocationNotesView(geohash: "u4pruydq", manager: loadedManager) + .environmentObject(viewModel) + ) + + #expect(loadedManager.notes.count == 1) + #expect(noRelayManager.state == .noRelays) + } + + @Test + func appInfoAndComponentViews_render() { + let feature = AppInfoFeatureInfo( + icon: "lock.fill", + title: "app_info.privacy.title", + description: "app_info.features.encryption.description" + ) + + let appInfo = AppInfoView() + let header = SectionHeader("app_info.features.title") + let featureRow = FeatureRow(info: feature) + let paymentCashu = PaymentChipView(paymentType: .cashu("cashuA_test-token")) + let paymentLightning = PaymentChipView(paymentType: .lightning("lightning:lnbc1test")) + + _ = appInfo.body + _ = header.body + _ = featureRow.body + _ = paymentCashu.body + _ = paymentLightning.body + _ = DeliveryStatusView(status: .sending).body + _ = DeliveryStatusView(status: .sent).body + _ = DeliveryStatusView(status: .delivered(to: "Alice", at: Date())).body + _ = DeliveryStatusView(status: .read(by: "Alice", at: Date())).body + _ = DeliveryStatusView(status: .failed(reason: "offline")).body + _ = DeliveryStatusView(status: .partiallyDelivered(reached: 2, total: 3)).body + _ = mount(appInfo) + _ = mount(header) + _ = mount(featureRow) + _ = mount(paymentCashu) + _ = mount(paymentLightning) + + #expect(PaymentChipView.PaymentType.cashu("cashuA_test-token").url?.scheme == "cashu") + #expect(PaymentChipView.PaymentType.cashu("https://example.com/cashu").url?.absoluteString == "https://example.com/cashu") + #expect(PaymentChipView.PaymentType.lightning("lightning:lnbc1test").url?.scheme == "lightning") + } + + @Test + func geohashAndTextMessageViews_renderCoreBranches() { + let (viewModel, _, _) = makeSmokeViewModel() + let geohashPeopleList = GeohashPeopleList( + viewModel: viewModel, + textColor: .green, + secondaryTextColor: .gray, + onTapPerson: {} + ) + var expandedMessageIDs: Set = [] + let longMessage = BitchatMessage( + sender: viewModel.nickname, + content: String(repeating: "verylongtoken", count: 12) + " lightning:lnbc1test cashuA_test-token", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Bob", + deliveryStatus: .partiallyDelivered(reached: 1, total: 2) + ) + + _ = geohashPeopleList.body + _ = mount(geohashPeopleList) + _ = mount( + TextMessageView( + message: longMessage, + expandedMessageIDs: Binding( + get: { expandedMessageIDs }, + set: { expandedMessageIDs = $0 } + ) + ) + .environmentObject(viewModel) + ) + + #expect(expandedMessageIDs.isEmpty) + } + + @Test + func voiceAndMediaViews_renderAndWarmCaches() async throws { + let audioURL = try makeTemporaryAudioURL() + let imageURL = try makeTemporaryImageURL() + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: imageURL) + WaveformCache.shared.purge(url: audioURL) + } + + let waveformView = WaveformView( + samples: [0.1, 0.6, 0.3, 0.8], + playbackProgress: 0.25, + sendProgress: 0.75, + onSeek: nil, + isInteractive: false + ) + let imageView = BlockRevealImageView( + url: imageURL, + revealProgress: 0.5, + isSending: true, + onCancel: {}, + initiallyBlurred: true, + onOpen: {}, + onDelete: {} + ) + let voiceNoteView = VoiceNoteView( + url: audioURL, + isSending: true, + sendProgress: 0.4, + onCancel: {} + ) + let playback = VoiceNotePlaybackController(url: audioURL) + + _ = waveformView.body + _ = imageView.body + _ = mount(waveformView) + _ = mount(imageView) + _ = mount(voiceNoteView) + + let bins = await withCheckedContinuation { continuation in + WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in + continuation.resume(returning: values) + } + } + playback.loadDuration() + try? await Task.sleep(nanoseconds: 250_000_000) + playback.seek(to: 1.25) + playback.stop() + VoiceNotePlaybackCoordinator.shared.activate(playback) + VoiceNotePlaybackCoordinator.shared.deactivate(playback) + VoiceRecorder.shared.cancelRecording() + + #expect(bins.count == 16) + #expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16) + #expect(playback.duration > 0) + #expect(playback.progress == 0) + #expect(VoiceRecorder.shared.currentAveragePower() <= 0) + } + + #if os(iOS) + @Test + func cameraScannerView_previewAndCoordinatorSmoke() { + let preview = CameraScannerView.PreviewView(frame: .zero) + let coordinator = CameraScannerView.Coordinator() + + _ = CameraScannerView.PreviewView.layerClass + _ = preview.videoPreviewLayer + coordinator.setup(sessionOwner: preview) { _ in } + coordinator.setActive(false) + + #expect(preview.videoPreviewLayer.videoGravity == .resizeAspectFill) + } + #endif +}