diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift new file mode 100644 index 00000000..503a260b --- /dev/null +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -0,0 +1,106 @@ +// +// BLEServiceCoreTests.swift +// bitchatTests +// +// Focused BLEService tests for packet handling behavior. +// + +import Testing +import Foundation +import CoreBluetooth +@testable import bitchat + +struct BLEServiceCoreTests { + + @Test + func duplicatePacket_isDeduped() async { + let ble = makeService() + let delegate = PublicCaptureDelegate() + ble.delegate = delegate + + let sender = PeerID(str: "1122334455667788") + let timestamp = UInt64(Date().timeIntervalSince1970 * 1000) + let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp) + + ble._test_handlePacket(packet, fromPeerID: sender) + ble._test_handlePacket(packet, fromPeerID: sender) + + _ = await TestHelpers.waitUntil({ delegate.publicMessagesSnapshot().count == 1 }, + timeout: TestConstants.shortTimeout) + + let messages = delegate.publicMessagesSnapshot() + #expect(messages.count == 1) + #expect(messages.first?.content == "Hello") + } + + @Test + func staleBroadcast_isIgnored() async { + let ble = makeService() + let delegate = PublicCaptureDelegate() + ble.delegate = delegate + + let sender = PeerID(str: "A1B2C3D4E5F60708") + let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000) + let packet = makePublicPacket(content: "Old", sender: sender, timestamp: oldTimestamp) + + ble._test_handlePacket(packet, fromPeerID: sender) + + let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3) + #expect(!didReceive) + #expect(delegate.publicMessagesSnapshot().isEmpty) + } +} + +private func makeService() -> BLEService { + let keychain = MockKeychain() + let identityManager = MockIdentityManager(keychain) + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + return BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager) +} + +private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket { + BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: nil, + timestamp: timestamp, + payload: Data(content.utf8), + signature: nil, + ttl: 3 + ) +} + +private final class PublicCaptureDelegate: BitchatDelegate { + private let lock = NSLock() + private(set) var publicMessages: [BitchatMessage] = [] + + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { + let message = BitchatMessage( + id: messageID, + sender: nickname, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: peerID, + mentions: nil + ) + lock.lock() + publicMessages.append(message) + lock.unlock() + } + + func didReceiveMessage(_ message: BitchatMessage) {} + func didConnectToPeer(_ peerID: PeerID) {} + func didDisconnectFromPeer(_ peerID: PeerID) {} + func didUpdatePeerList(_ peers: [PeerID]) {} + func didUpdateBluetoothState(_ state: CBManagerState) {} + + func publicMessagesSnapshot() -> [BitchatMessage] { + lock.lock() + defer { lock.unlock() } + return publicMessages + } +} diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift new file mode 100644 index 00000000..30bb17c5 --- /dev/null +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -0,0 +1,228 @@ +// +// ChatViewModelDeliveryStatusTests.swift +// bitchatTests +// +// Tests for ChatViewModel delivery status state machine. +// + +import Testing +import Foundation +@testable import bitchat + +// MARK: - Test Helpers + +@MainActor +private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { + 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) +} + +// MARK: - Delivery Status Tests + +struct ChatViewModelDeliveryStatusTests { + + // MARK: - Status Transition Tests + + @Test @MainActor + func deliveryStatus_noDowngrade_readToDelivered() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let messageID = "test-msg-1" + + // Setup: create a message with .read status + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Test message", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .read(by: "Peer", at: Date()) + ) + viewModel.privateChats[peerID] = [message] + + // Action: try to downgrade to .delivered + viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date())) + + // Assert: status should remain .read (no downgrade) + let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus + #expect({ + if case .read = currentStatus { return true } + return false + }()) + } + + @Test @MainActor + func deliveryStatus_upgrade_sentToDelivered() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let messageID = "test-msg-2" + + // Setup: create a message with .sent status + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Test message", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .sent + ) + viewModel.privateChats[peerID] = [message] + + // Action: upgrade to .delivered + viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date())) + + // Assert: status should be .delivered + let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus + #expect({ + if case .delivered = currentStatus { return true } + return false + }()) + } + + @Test @MainActor + func deliveryStatus_upgrade_deliveredToRead() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let messageID = "test-msg-3" + + // Setup: create a message with .delivered status + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Test message", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60)) + ) + viewModel.privateChats[peerID] = [message] + + // Action: upgrade to .read + viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date())) + + // Assert: status should be .read + let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus + #expect({ + if case .read = currentStatus { return true } + return false + }()) + } + + // MARK: - Read Receipt Handling + + @Test @MainActor + func didReceiveReadReceipt_updatesMessageStatus() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let messageID = "test-msg-4" + + // Setup: create a message with .sent status + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Test message", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .sent + ) + viewModel.privateChats[peerID] = [message] + + // Action: receive read receipt + let receipt = ReadReceipt( + originalMessageID: messageID, + readerID: peerID, + readerNickname: "Peer" + ) + viewModel.didReceiveReadReceipt(receipt) + + // Assert: status should be .read + let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus + #expect({ + if case .read = currentStatus { return true } + return false + }()) + } + + // MARK: - Public Timeline Status Tests + + @Test @MainActor + func deliveryStatus_publicTimeline_updatesCorrectly() async { + let (viewModel, _) = makeTestableViewModel() + let messageID = "public-msg-1" + + // Setup: add a message to public timeline with .sending status + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Public message", + timestamp: Date(), + isRelay: false, + isPrivate: false, + deliveryStatus: .sending + ) + viewModel.messages.append(message) + + // Action: update to .sent + viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent) + + // Assert + let updatedMessage = viewModel.messages.first(where: { $0.id == messageID }) + #expect({ + if case .sent = updatedMessage?.deliveryStatus { return true } + return false + }()) + } + + // MARK: - Status Rank Tests (for deduplication) + + @Test @MainActor + func statusRank_orderingIsCorrect() async { + // This tests the implicit ordering used in refreshVisibleMessages + // failed < sending < sent < partiallyDelivered < delivered < read + + let statuses: [DeliveryStatus] = [ + .failed(reason: "test"), + .sending, + .sent, + .partiallyDelivered(reached: 1, total: 3), + .delivered(to: "B", at: Date()), + .read(by: "C", at: Date()) + ] + + // Verify each status has a logical progression + // This is more of a documentation test to ensure the ranking logic is understood + for (index, status) in statuses.enumerated() { + switch status { + case .failed: #expect(index == 0) + case .sending: #expect(index == 1) + case .sent: #expect(index == 2) + case .partiallyDelivered: #expect(index == 3) + case .delivered: #expect(index == 4) + case .read: #expect(index == 5) + } + } + } +} diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 6c04d35c..af2dc61d 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -64,6 +64,22 @@ struct ChatViewModelPrivateChatExtensionTests { // MockTransport.sendPrivateMessage is what MessageRouter calls for connected peers // Check MockTransport implementation... it might need update or verification } + + @Test @MainActor + func sendPrivateMessage_unreachable_setsFailedStatus() async { + let (viewModel, _) = makeTestableViewModel() + let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10" + let peerID = PeerID(str: validHex) + + viewModel.sendPrivateMessage("Hello", to: peerID) + + #expect(viewModel.privateChats[peerID]?.count == 1) + let status = viewModel.privateChats[peerID]?.last?.deliveryStatus + #expect({ + if case .failed = status { return true } + return false + }()) + } @Test @MainActor func handlePrivateMessage_storesMessage() async { @@ -250,10 +266,17 @@ struct ChatViewModelNostrExtensionTests { @Test @MainActor func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async { - let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" + let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) + + LocationChannelManager.shared.select(channel) + defer { LocationChannelManager.shared.select(.mesh) } + + _ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel }) + + let (viewModel, _) = makeTestableViewModel() - viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + _ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }) var event = NostrEvent( pubkey: "pub1", @@ -267,19 +290,119 @@ struct ChatViewModelNostrExtensionTests { viewModel.handleNostrEvent(event) - // Allow async processing + let didAppend = await TestHelpers.waitUntil({ + viewModel.publicMessagePipeline.flushIfNeeded() + return viewModel.messages.contains { $0.content == "Hello Geo" } + }) + #expect(didAppend) + } + + @Test @MainActor + func handleNostrEvent_ignoresRecentSelfEcho() async throws { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash) + + var event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [["g", geohash]], + content: "Self echo" + ) + event.id = "evt-self" + + viewModel.handleNostrEvent(event) + try? await Task.sleep(nanoseconds: 100_000_000) - - // Check timeline - // This depends on `handlePublicMessage` being called and updating `messages` - // Since `handlePublicMessage` delegates to `timelineStore` and updates `messages`... - // And we are in the correct channel... - - // However, `handleNostrEvent` in the extension now calls `handlePublicMessage`. - // Let's verify if the message appears. - // Note: `handleNostrEvent` logic was refactored. - // The new logic in `ChatViewModel+Nostr.swift` calls `handlePublicMessage`. - - // We need to ensure `deduplicationService` doesn't block it (new instance, so empty). + viewModel.publicMessagePipeline.flushIfNeeded() + + #expect(!viewModel.messages.contains { $0.content == "Self echo" }) + } + + @Test @MainActor + func handleNostrEvent_skipsBlockedSender() async { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + let blockedPubkey = "0000000000000000000000000000000000000000000000000000000000000001" + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true) + + var event = NostrEvent( + pubkey: blockedPubkey, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [["g", geohash]], + content: "Blocked" + ) + event.id = "evt-blocked" + + viewModel.handleNostrEvent(event) + + try? await Task.sleep(nanoseconds: 100_000_000) + viewModel.publicMessagePipeline.flushIfNeeded() + + #expect(!viewModel.messages.contains { $0.content == "Blocked" }) + } + + @Test @MainActor + func switchLocationChannel_clearsNostrDedupCache() async { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + + viewModel.deduplicationService.recordNostrEvent("evt-cache") + #expect(viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache")) + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + + #expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache")) + } +} + +// MARK: - Geohash Queue Tests + +struct ChatViewModelGeohashQueueTests { + + @Test @MainActor + func addGeohashOnlySystemMessage_queuesUntilLocationChannel() async { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + + viewModel.addGeohashOnlySystemMessage("Queued system") + #expect(!viewModel.messages.contains { $0.content == "Queued system" }) + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + + #expect(viewModel.messages.contains { $0.content == "Queued system" }) + } +} + +// MARK: - GeoDM Tests + +struct ChatViewModelGeoDMTests { + + @Test @MainActor + func handlePrivateMessage_geohash_dedupsAndTracksAck() async throws { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000001" + let messageID = "pm-1" + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash) + + let convKey = PeerID(nostr_: senderPubkey) + 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()) + viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date()) + + #expect(viewModel.privateChats[convKey]?.count == 1) + #expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) } } diff --git a/bitchatTests/ChatViewModelRefactoringTests.swift b/bitchatTests/ChatViewModelRefactoringTests.swift new file mode 100644 index 00000000..713ea036 --- /dev/null +++ b/bitchatTests/ChatViewModelRefactoringTests.swift @@ -0,0 +1,154 @@ +// +// ChatViewModelRefactoringTests.swift +// bitchatTests +// +// Pinning tests to characterize ChatViewModel behavior before refactoring. +// These tests act as a safety net to ensure we don't break existing functionality. +// + +import Testing +import Foundation +@testable import bitchat + +struct ChatViewModelRefactoringTests { + + // Helper to setup the environment + @MainActor + private func makePinnedViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identity: 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) + } + + // MARK: - Command Processor Integration "Pinning" + + @Test @MainActor + func command_msg_routesToTransport() async throws { + let (viewModel, transport, _) = makePinnedViewModel() + + // Setup: Use simulateConnect so ChatViewModel and UnifiedPeerService are notified + let peerID = PeerID(str: "0000000000000001") + transport.simulateConnect(peerID, nickname: "alice") + + let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil }, + timeout: TestConstants.shortTimeout) + #expect(didResolve) + + // Action: User types /msg command + viewModel.sendMessage("/msg @alice Hello Private World") + + let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 }, + timeout: TestConstants.shortTimeout) + #expect(didSend) + + // Assert: + // 1. Should NOT go to public transport + #expect(transport.sentMessages.isEmpty, "Command should not be sent as public message") + + // 2. Should go to private transport logic + #expect(transport.sentPrivateMessages.count == 1) + #expect(transport.sentPrivateMessages.first?.content == "Hello Private World") + #expect(transport.sentPrivateMessages.first?.peerID == peerID) + } + + @Test @MainActor + func command_block_updatesIdentity() async throws { + let (viewModel, transport, identity) = makePinnedViewModel() + + // Setup: Use simulateConnect + let peerID = PeerID(str: "0000000000000002") + // Mock the fingerprint so the block command finds it + transport.peerFingerprints[peerID] = "fingerprint_123" + transport.simulateConnect(peerID, nickname: "troll") + + let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil }, + timeout: TestConstants.shortTimeout) + #expect(didResolve) + + // Action + viewModel.sendMessage("/block @troll") + + // Assert + // Verify identity manager was called to block "fingerprint_123" + let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") }, + timeout: TestConstants.shortTimeout) + #expect(didBlock) + } + + // MARK: - Message Routing Logic + + @Test @MainActor + func routing_incomingPrivateMessage_addsToPrivateChats() async { + let (viewModel, _, _) = makePinnedViewModel() + let senderID = PeerID(str: "sender_1") + + // Setup + let message = BitchatMessage( + id: "msg_1", + sender: "bob", + content: "Secret", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: "me", + senderPeerID: senderID, + mentions: nil + ) + + // Action: Simulate incoming private message + viewModel.didReceiveMessage(message) + + // Wait for async processing with proper timeout + let found = await TestHelpers.waitUntil( + { viewModel.privateChats[senderID]?.first?.content == "Secret" }, + timeout: TestConstants.defaultTimeout + ) + + // Assert + #expect(found) + } + + @Test @MainActor + func routing_incomingPublicMessage_addsToPublicTimeline() async { + let (viewModel, _, _) = makePinnedViewModel() + let senderID = PeerID(str: "sender_2") + + // Setup + let message = BitchatMessage( + id: "msg_2", + sender: "charlie", + content: "Public Hi", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: senderID, + mentions: nil + ) + + // Action + viewModel.didReceiveMessage(message) + + // Wait for async processing with proper timeout + let found = await TestHelpers.waitUntil( + { viewModel.messages.contains(where: { $0.content == "Public Hi" }) }, + timeout: TestConstants.defaultTimeout + ) + + // Assert + #expect(found) + } +} diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 209757bd..8fef7eca 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -114,6 +114,44 @@ struct ChatViewModelSendingTests { } } +// MARK: - Command Handling Tests + +struct ChatViewModelCommandTests { + + @Test @MainActor + func sendMessage_commandsNotSentToTransport() async { + let (viewModel, transport) = makeTestableViewModel() + let commands = ["/nick bob", "/who", "/help", "/clear"] + + for command in commands { + transport.resetRecordings() + viewModel.sendMessage(command) + try? await Task.sleep(nanoseconds: 100_000_000) + + #expect(transport.sentMessages.isEmpty) + #expect(transport.sentPrivateMessages.isEmpty) + } + } +} + +// MARK: - Timeline Cap Tests + +struct ChatViewModelTimelineCapTests { + + @Test @MainActor + func sendMessage_trimsTimelineToCap() async { + let (viewModel, _) = makeTestableViewModel() + let total = TransportConfig.meshTimelineCap + 5 + + for i in 0.. (viewModel: ChatViewModel, transport: MockTransport) { + 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) +} + +// MARK: - Tor Notification Handler Tests + +struct ChatViewModelTorTests { + + // MARK: - handleTorWillStart Tests + + @Test @MainActor + func handleTorWillStart_whenEnforced_setsAnnouncedFlag() async { + let (viewModel, _) = makeTestableViewModel() + + // Precondition: flag should start false + #expect(!viewModel.torStatusAnnounced) + + // Action: simulate Tor starting notification + viewModel.handleTorWillStart() + + // Wait for Task to complete + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert: flag should be set (torEnforced is true in tests) + #expect(viewModel.torStatusAnnounced) + } + + @Test @MainActor + func handleTorWillStart_whenAlreadyAnnounced_doesNotDuplicate() async { + let (viewModel, _) = makeTestableViewModel() + + // Setup: pre-set the flag + viewModel.torStatusAnnounced = true + + // Switch to a geohash channel so messages would be visible + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq"))) + try? await Task.sleep(nanoseconds: 100_000_000) + + let initialMessageCount = viewModel.messages.count + + // Action: call handler again + viewModel.handleTorWillStart() + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert: no new message added (flag was already true) + #expect(viewModel.messages.count == initialMessageCount) + } + + // MARK: - handleTorWillRestart Tests + + @Test @MainActor + func handleTorWillRestart_setsPendingFlag() async { + let (viewModel, _) = makeTestableViewModel() + + // Precondition + #expect(!viewModel.torRestartPending) + + // Action + viewModel.handleTorWillRestart() + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert + #expect(viewModel.torRestartPending) + } + + @Test @MainActor + func handleTorWillRestart_setsFlag_regardlessOfChannel() async { + let (viewModel, _) = makeTestableViewModel() + + // Action: call handler (works regardless of channel) + viewModel.handleTorWillRestart() + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert: flag should be set + #expect(viewModel.torRestartPending) + } + + // MARK: - handleTorDidBecomeReady Tests + + @Test @MainActor + func handleTorDidBecomeReady_afterRestart_clearsPendingFlag() async { + let (viewModel, _) = makeTestableViewModel() + + // Setup: simulate restart pending state + viewModel.torRestartPending = true + + // Action + viewModel.handleTorDidBecomeReady() + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert: should clear pending flag + #expect(!viewModel.torRestartPending) + } + + @Test @MainActor + func handleTorDidBecomeReady_initialStart_setsAnnouncedFlag() async { + let (viewModel, _) = makeTestableViewModel() + + // Setup: not restarting, but initial ready not announced yet + viewModel.torRestartPending = false + viewModel.torInitialReadyAnnounced = false + + // Action + viewModel.handleTorDidBecomeReady() + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert: should set flag (torEnforced is true in tests) + #expect(viewModel.torInitialReadyAnnounced) + } + + @Test @MainActor + func handleTorDidBecomeReady_alreadyAnnounced_noDuplicate() async { + let (viewModel, _) = makeTestableViewModel() + + // Setup: already announced initial ready + viewModel.torRestartPending = false + viewModel.torInitialReadyAnnounced = true + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq"))) + try? await Task.sleep(nanoseconds: 100_000_000) + + let initialMessageCount = viewModel.messages.count + + // Action + viewModel.handleTorDidBecomeReady() + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert: no new message + #expect(viewModel.messages.count == initialMessageCount) + } + + // MARK: - handleTorPreferenceChanged Tests + + @Test @MainActor + func handleTorPreferenceChanged_resetsAllFlags() async { + let (viewModel, _) = makeTestableViewModel() + + // Setup: set all flags + viewModel.torStatusAnnounced = true + viewModel.torInitialReadyAnnounced = true + viewModel.torRestartPending = true + + // Action + viewModel.handleTorPreferenceChanged(Notification(name: .init("test"))) + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert: all flags reset + #expect(!viewModel.torStatusAnnounced) + #expect(!viewModel.torInitialReadyAnnounced) + #expect(!viewModel.torRestartPending) + } +} diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift index 6e8f802a..7d6cb8df 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -13,6 +13,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { private let keychain: KeychainManagerProtocol private var blockedFingerprints: Set = [] private var blockedNostrPubkeys: Set = [] + private var socialIdentities: [String: SocialIdentity] = [:] init(_ keychain: KeychainManagerProtocol) { self.keychain = keychain @@ -25,7 +26,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { func forceSave() {} func getSocialIdentity(for fingerprint: String) -> SocialIdentity? { - nil + socialIdentities[fingerprint] } func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {} @@ -34,7 +35,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { [] } - func updateSocialIdentity(_ identity: SocialIdentity) {} + func updateSocialIdentity(_ identity: SocialIdentity) { + socialIdentities[identity.fingerprint] = identity + if identity.isBlocked { + blockedFingerprints.insert(identity.fingerprint) + } else { + blockedFingerprints.remove(identity.fingerprint) + } + } func getFavorites() -> Set { Set() @@ -47,10 +55,25 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { } func isBlocked(fingerprint: String) -> Bool { - blockedFingerprints.contains(fingerprint) + blockedFingerprints.contains(fingerprint) || socialIdentities[fingerprint]?.isBlocked == true } func setBlocked(_ fingerprint: String, isBlocked: Bool) { + if var identity = socialIdentities[fingerprint] { + identity.isBlocked = isBlocked + socialIdentities[fingerprint] = identity + } else { + let identity = SocialIdentity( + fingerprint: fingerprint, + localPetname: nil, + claimedNickname: "", + trustLevel: .unknown, + isFavorite: false, + isBlocked: isBlocked, + notes: nil + ) + socialIdentities[fingerprint] = identity + } if isBlocked { blockedFingerprints.insert(fingerprint) } else { diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index d65a10d1..a22bc15d 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -184,6 +184,7 @@ final class MockTransport: Transport { } delegate?.didConnectToPeer(peerID) delegate?.didUpdatePeerList(Array(connectedPeers)) + publishPeerSnapshots() } /// Simulates a peer disconnecting @@ -192,6 +193,7 @@ final class MockTransport: Transport { peerNicknames.removeValue(forKey: peerID) delegate?.didDisconnectFromPeer(peerID) delegate?.didUpdatePeerList(Array(connectedPeers)) + publishPeerSnapshots() } /// Simulates receiving a message @@ -224,5 +226,22 @@ final class MockTransport: Transport { /// Updates the peer snapshot publisher func updatePeerSnapshots(_ snapshots: [TransportPeerSnapshot]) { peerSnapshotSubject.send(snapshots) + Task { @MainActor [weak self] in + self?.peerEventsDelegate?.didUpdatePeerSnapshots(snapshots) + } + } + + private func publishPeerSnapshots() { + let now = Date() + let snapshots = connectedPeers.map { peerID in + TransportPeerSnapshot( + peerID: peerID, + nickname: peerNicknames[peerID] ?? "", + isConnected: true, + noisePublicKey: Data(hexString: peerID.bare), + lastSeen: now + ) + } + updatePeerSnapshots(snapshots) } } diff --git a/bitchatTests/PublicMessagePipelineTests.swift b/bitchatTests/PublicMessagePipelineTests.swift new file mode 100644 index 00000000..dd716cdc --- /dev/null +++ b/bitchatTests/PublicMessagePipelineTests.swift @@ -0,0 +1,166 @@ +// +// PublicMessagePipelineTests.swift +// bitchatTests +// +// Tests for PublicMessagePipeline ordering and deduplication. +// + +import Testing +import Foundation +@testable import bitchat + +@MainActor +private final class TestPipelineDelegate: PublicMessagePipelineDelegate { + private let dedupService = MessageDeduplicationService() + var messages: [BitchatMessage] = [] + + func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] { + messages + } + + func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) { + self.messages = messages + } + + func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String { + dedupService.normalizedContentKey(content) + } + + func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { + dedupService.contentTimestamp(forKey: key) + } + + func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { + dedupService.recordContentKey(key, timestamp: timestamp) + } + + func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {} + + func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {} + + func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {} +} + +struct PublicMessagePipelineTests { + + @Test @MainActor + func flush_sortsByTimestamp() async { + let pipeline = PublicMessagePipeline() + let delegate = TestPipelineDelegate() + pipeline.delegate = delegate + + let earlier = Date().addingTimeInterval(-10) + let later = Date() + + let messageA = BitchatMessage( + id: "a", + sender: "A", + content: "Later", + timestamp: later, + isRelay: false + ) + let messageB = BitchatMessage( + id: "b", + sender: "A", + content: "Earlier", + timestamp: earlier, + isRelay: false + ) + + pipeline.enqueue(messageA) + pipeline.enqueue(messageB) + pipeline.flushIfNeeded() + + #expect(delegate.messages.map { $0.id } == ["b", "a"]) + } + + @Test @MainActor + func flush_deduplicatesByContentWithinWindow() async { + let pipeline = PublicMessagePipeline() + let delegate = TestPipelineDelegate() + pipeline.delegate = delegate + + let now = Date() + let messageA = BitchatMessage( + id: "a", + sender: "A", + content: "Same", + timestamp: now, + isRelay: false + ) + let messageB = BitchatMessage( + id: "b", + sender: "A", + content: "Same", + timestamp: now.addingTimeInterval(0.2), + isRelay: false + ) + + pipeline.enqueue(messageA) + pipeline.enqueue(messageB) + pipeline.flushIfNeeded() + + #expect(delegate.messages.count == 1) + #expect(delegate.messages.first?.content == "Same") + } + + @Test @MainActor + func lateInsert_meshAppendsRecentOlderMessage() async { + let pipeline = PublicMessagePipeline() + let delegate = TestPipelineDelegate() + pipeline.delegate = delegate + pipeline.updateActiveChannel(.mesh) + + let base = Date() + let newer = BitchatMessage( + id: "new", + sender: "A", + content: "New", + timestamp: base, + isRelay: false + ) + let older = BitchatMessage( + id: "old", + sender: "A", + content: "Old", + timestamp: base.addingTimeInterval(-5), + isRelay: false + ) + + delegate.messages = [newer] + pipeline.enqueue(older) + pipeline.flushIfNeeded() + + #expect(delegate.messages.map { $0.id } == ["new", "old"]) + } + + @Test @MainActor + func lateInsert_locationInsertsByTimestamp() async { + let pipeline = PublicMessagePipeline() + let delegate = TestPipelineDelegate() + pipeline.delegate = delegate + pipeline.updateActiveChannel(.location(GeohashChannel(level: .city, geohash: "u4pruydq"))) + + let base = Date() + let newer = BitchatMessage( + id: "new", + sender: "A", + content: "New", + timestamp: base, + isRelay: false + ) + let older = BitchatMessage( + id: "old", + sender: "A", + content: "Old", + timestamp: base.addingTimeInterval(-5), + isRelay: false + ) + + delegate.messages = [newer] + pipeline.enqueue(older) + pipeline.flushIfNeeded() + + #expect(delegate.messages.map { $0.id } == ["old", "new"]) + } +} diff --git a/bitchatTests/Services/MessageRouterTests.swift b/bitchatTests/Services/MessageRouterTests.swift new file mode 100644 index 00000000..837157ef --- /dev/null +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -0,0 +1,68 @@ +// +// MessageRouterTests.swift +// bitchatTests +// +// Tests for MessageRouter transport selection and outbox behavior. +// + +import Testing +import Foundation +@testable import bitchat + +struct MessageRouterTests { + + @Test @MainActor + func sendPrivate_usesReachableTransport() async { + let peerID = PeerID(str: "0000000000000001") + let transportA = MockTransport() + let transportB = MockTransport() + transportB.reachablePeers.insert(peerID) + + let router = MessageRouter(transports: [transportA, transportB]) + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m1") + + #expect(transportA.sentPrivateMessages.isEmpty) + #expect(transportB.sentPrivateMessages.count == 1) + } + + @Test @MainActor + func sendPrivate_queuesThenFlushesWhenReachable() async { + let peerID = PeerID(str: "0000000000000002") + let transport = MockTransport() + + let router = MessageRouter(transports: [transport]) + router.sendPrivate("Queued", to: peerID, recipientNickname: "Peer", messageID: "m2") + + #expect(transport.sentPrivateMessages.isEmpty) + + transport.reachablePeers.insert(peerID) + router.flushOutbox(for: peerID) + + #expect(transport.sentPrivateMessages.count == 1) + } + + @Test @MainActor + func sendReadReceipt_usesReachableTransport() async { + let peerID = PeerID(str: "0000000000000003") + let transport = MockTransport() + transport.reachablePeers.insert(peerID) + + let router = MessageRouter(transports: [transport]) + let receipt = ReadReceipt(originalMessageID: "m3", readerID: transport.myPeerID, readerNickname: "Me") + router.sendReadReceipt(receipt, to: peerID) + + #expect(transport.sentReadReceipts.count == 1) + } + + @Test @MainActor + func sendFavoriteNotification_usesConnectedOrReachable() async { + let peerID = PeerID(str: "0000000000000004") + let transport = MockTransport() + transport.reachablePeers.insert(peerID) + + let router = MessageRouter(transports: [transport]) + router.sendFavoriteNotification(to: peerID, isFavorite: true) + + #expect(transport.sentFavoriteNotifications.count == 1) + } +} diff --git a/bitchatTests/Services/PrivateChatManagerTests.swift b/bitchatTests/Services/PrivateChatManagerTests.swift new file mode 100644 index 00000000..ecdba5a3 --- /dev/null +++ b/bitchatTests/Services/PrivateChatManagerTests.swift @@ -0,0 +1,72 @@ +// +// PrivateChatManagerTests.swift +// bitchatTests +// +// Tests for PrivateChatManager read receipt and selection behavior. +// + +import Testing +import Foundation +@testable import bitchat + +struct PrivateChatManagerTests { + + @Test @MainActor + func startChat_setsSelectedAndClearsUnread() async { + let transport = MockTransport() + let manager = PrivateChatManager(meshService: transport) + let peerID = PeerID(str: "00000000000000AA") + + manager.privateChats[peerID] = [ + BitchatMessage( + id: "pm-1", + sender: "Peer", + content: "Hi", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerID + ) + ] + manager.unreadMessages.insert(peerID) + + manager.startChat(with: peerID) + + #expect(manager.selectedPeer == peerID) + #expect(!manager.unreadMessages.contains(peerID)) + #expect(manager.privateChats[peerID] != nil) + } + + @Test @MainActor + func markAsRead_sendsReadReceiptViaRouter() async { + let transport = MockTransport() + let router = MessageRouter(transports: [transport]) + let manager = PrivateChatManager(meshService: transport) + manager.messageRouter = router + + let peerID = PeerID(str: "00000000000000BB") + transport.reachablePeers.insert(peerID) + + manager.privateChats[peerID] = [ + BitchatMessage( + id: "pm-2", + sender: "Peer", + content: "Hi", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerID + ) + ] + manager.unreadMessages.insert(peerID) + + manager.markAsRead(from: peerID) + try? await Task.sleep(nanoseconds: 100_000_000) + + #expect(transport.sentReadReceipts.count == 1) + #expect(manager.sentReadReceipts.contains("pm-2")) + #expect(!manager.unreadMessages.contains(peerID)) + } +} diff --git a/bitchatTests/Services/RelayControllerTests.swift b/bitchatTests/Services/RelayControllerTests.swift new file mode 100644 index 00000000..71553ab8 --- /dev/null +++ b/bitchatTests/Services/RelayControllerTests.swift @@ -0,0 +1,95 @@ +// +// RelayControllerTests.swift +// bitchatTests +// +// Tests for relay decision logic. +// + +import Testing +import Foundation +@testable import bitchat + +struct RelayControllerTests { + + @Test + func ttlOne_doesNotRelay() async { + let decision = RelayController.decide( + ttl: 1, + senderIsSelf: false, + isEncrypted: false, + isDirectedEncrypted: false, + isFragment: false, + isDirectedFragment: false, + isHandshake: false, + isAnnounce: false, + degree: 0, + highDegreeThreshold: TransportConfig.bleHighDegreeThreshold + ) + + #expect(!decision.shouldRelay) + #expect(decision.newTTL == 1) + } + + @Test + func handshake_alwaysRelaysWithTTLDecrement() async { + let decision = RelayController.decide( + ttl: 3, + senderIsSelf: false, + isEncrypted: false, + isDirectedEncrypted: false, + isFragment: false, + isDirectedFragment: false, + isHandshake: true, + isAnnounce: false, + degree: 3, + highDegreeThreshold: TransportConfig.bleHighDegreeThreshold + ) + + #expect(decision.shouldRelay) + #expect(decision.newTTL == 2) + #expect(decision.delayMs >= 10 && decision.delayMs <= 35) + } + + @Test + func fragment_relaysWithFragmentCap() async { + let decision = RelayController.decide( + ttl: 10, + senderIsSelf: false, + isEncrypted: false, + isDirectedEncrypted: false, + isFragment: true, + isDirectedFragment: false, + isHandshake: false, + isAnnounce: false, + degree: 3, + highDegreeThreshold: TransportConfig.bleHighDegreeThreshold + ) + + let ttlCap = min(UInt8(10), TransportConfig.bleFragmentRelayTtlCap) + let expected = ttlCap &- 1 + + #expect(decision.shouldRelay) + #expect(decision.newTTL == expected) + #expect(decision.delayMs >= TransportConfig.bleFragmentRelayMinDelayMs) + #expect(decision.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs) + } + + @Test + func denseGraph_capsTTL() async { + let decision = RelayController.decide( + ttl: 10, + senderIsSelf: false, + isEncrypted: false, + isDirectedEncrypted: false, + isFragment: false, + isDirectedFragment: false, + isHandshake: false, + isAnnounce: false, + degree: TransportConfig.bleHighDegreeThreshold, + highDegreeThreshold: TransportConfig.bleHighDegreeThreshold + ) + + #expect(decision.shouldRelay) + #expect(decision.newTTL == 4) + } +} diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift new file mode 100644 index 00000000..192fa1b4 --- /dev/null +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -0,0 +1,145 @@ +// +// UnifiedPeerServiceTests.swift +// bitchatTests +// +// Tests for UnifiedPeerService fingerprint and block resolution. +// + +import Testing +import Foundation +@testable import bitchat + +struct UnifiedPeerServiceTests { + + @Test @MainActor + func getFingerprint_prefersMeshService() async { + let transport = MockTransport() + let identity = TestIdentityManager() + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity) + + let peerID = PeerID(str: "00000000000000CC") + transport.peerFingerprints[peerID] = "fp-1" + + let fingerprint = service.getFingerprint(for: peerID) + + #expect(fingerprint == "fp-1") + } + + @Test @MainActor + func isBlocked_usesSocialIdentity() async { + let transport = MockTransport() + let identity = TestIdentityManager() + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity) + + let peerID = PeerID(str: "00000000000000DD") + let fingerprint = "fp-blocked" + transport.peerFingerprints[peerID] = fingerprint + identity.setBlocked(fingerprint, isBlocked: true) + + #expect(service.isBlocked(peerID)) + } +} + +private final class TestIdentityManager: SecureIdentityStateManagerProtocol { + private var socialIdentities: [String: SocialIdentity] = [:] + private var favorites: Set = [] + private var blockedNostr: Set = [] + private var verified: Set = [] + + func forceSave() {} + + func getSocialIdentity(for fingerprint: String) -> SocialIdentity? { + socialIdentities[fingerprint] + } + + func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {} + + func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] { + [] + } + + func updateSocialIdentity(_ identity: SocialIdentity) { + socialIdentities[identity.fingerprint] = identity + } + + func getFavorites() -> Set { + favorites + } + + func setFavorite(_ fingerprint: String, isFavorite: Bool) { + if isFavorite { + favorites.insert(fingerprint) + } else { + favorites.remove(fingerprint) + } + } + + func isFavorite(fingerprint: String) -> Bool { + favorites.contains(fingerprint) + } + + func isBlocked(fingerprint: String) -> Bool { + socialIdentities[fingerprint]?.isBlocked ?? false + } + + func setBlocked(_ fingerprint: String, isBlocked: Bool) { + var identity = socialIdentities[fingerprint] ?? SocialIdentity( + fingerprint: fingerprint, + localPetname: nil, + claimedNickname: "", + trustLevel: .unknown, + isFavorite: false, + isBlocked: false, + notes: nil + ) + identity.isBlocked = isBlocked + socialIdentities[fingerprint] = identity + } + + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { + blockedNostr.contains(pubkeyHexLowercased) + } + + func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { + if isBlocked { + blockedNostr.insert(pubkeyHexLowercased) + } else { + blockedNostr.remove(pubkeyHexLowercased) + } + } + + func getBlockedNostrPubkeys() -> Set { + blockedNostr + } + + func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {} + + func updateHandshakeState(peerID: PeerID, state: HandshakeState) {} + + func clearAllIdentityData() { + socialIdentities.removeAll() + favorites.removeAll() + blockedNostr.removeAll() + verified.removeAll() + } + + func removeEphemeralSession(peerID: PeerID) {} + + func setVerified(fingerprint: String, verified: Bool) { + if verified { + self.verified.insert(fingerprint) + } else { + self.verified.remove(fingerprint) + } + } + + func isVerified(fingerprint: String) -> Bool { + verified.contains(fingerprint) + } + + func getVerifiedFingerprints() -> Set { + verified + } +} diff --git a/bitchatTests/TestUtilities/TestHelpers.swift b/bitchatTests/TestUtilities/TestHelpers.swift index b6236629..57200415 100644 --- a/bitchatTests/TestUtilities/TestHelpers.swift +++ b/bitchatTests/TestUtilities/TestHelpers.swift @@ -93,6 +93,22 @@ final class TestHelpers { try await sleep(0.01) } } + + @MainActor + static func waitUntil( + _ condition: @escaping () -> Bool, + timeout: TimeInterval = TestConstants.defaultTimeout, + pollInterval: TimeInterval = 0.01 + ) async -> Bool { + let start = Date() + while !condition() { + if Date().timeIntervalSince(start) > timeout { + return condition() + } + try? await sleep(pollInterval) + } + return true + } static func expectAsync( timeout: TimeInterval = TestConstants.defaultTimeout,