mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
BLEService's per-packet-type orchestration moves into owned, tested components (BLEAnnounceHandler, BLEPublicMessageHandler, BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler), each taking an environment struct of closures so every queue hop stays in BLEService and the handlers are synchronously testable. Behavior is preserved verbatim, including Noise session recovery on decrypt failure and single-block UI event ordering. handleLeave/handleRequestSync stay in place as already-thin delegations. BLEService drops to 3393 lines. Four coordinators (delivery, private conversation, Nostr, public conversation) drop their unowned/weak ChatViewModel back-references for narrow @MainActor context protocols, with ChatViewModel conformances as single shared witnesses for overlapping members. Their true coupling is now an explicit, reviewable surface, and each gains a mock-context test suite covering flows previously testable only through the full view model. Delivery/read acks now also clear the router's retained-send outbox via the delivery context. New LargeTopologyTests exercise production-shaped meshes with the in-memory harness: an 8-peer relay chain with per-hop TTL decay, a 14-peer cyclic mesh with exactly-once delivery, partition/heal, and topology churn. App-layer runtime/model files updated alongside. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
443 lines
18 KiB
Swift
443 lines
18 KiB
Swift
//
|
|
// ChatNostrCoordinatorContextTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Exercises `ChatNostrCoordinator` against a mock `ChatNostrContext` —
|
|
// proving the coordinator works without a `ChatViewModel`, following the
|
|
// `ChatDeliveryCoordinatorContextTests` / `ChatPrivateConversationCoordinatorContextTests`
|
|
// exemplars.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
// MARK: - Mock Context
|
|
|
|
/// Lightweight stand-in for `ChatNostrContext` proving that
|
|
/// `ChatNostrCoordinator` is testable without a `ChatViewModel`.
|
|
@MainActor
|
|
private final class MockChatNostrContext: ChatNostrContext {
|
|
// Channel & subscription state
|
|
var activeChannel: ChannelID = .mesh
|
|
var currentGeohash: String?
|
|
var geoSubscriptionID: String?
|
|
var geoDmSubscriptionID: String?
|
|
var geoSamplingSubs: [String: String] = [:]
|
|
var lastGeoNotificationAt: [String: Date] = [:]
|
|
var nostrRelayManager: NostrRelayManager? { nil }
|
|
|
|
// Public timeline & pipeline
|
|
var messages: [BitchatMessage] = []
|
|
private(set) var pipelineResetCount = 0
|
|
private(set) var pipelineChannelUpdates: [ChannelID] = []
|
|
private(set) var refreshedChannels: [ChannelID?] = []
|
|
private(set) var publicSystemMessages: [String] = []
|
|
var pendingGeohashSystemMessages: [String] = []
|
|
private(set) var appendedGeohashMessages: [(message: BitchatMessage, geohash: String)] = []
|
|
private(set) var synchronizedGeohashes: [String] = []
|
|
|
|
func resetPublicMessagePipeline() { pipelineResetCount += 1 }
|
|
func updatePublicMessagePipelineChannel(_ channel: ChannelID) { pipelineChannelUpdates.append(channel) }
|
|
func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) }
|
|
func addPublicSystemMessage(_ content: String) { publicSystemMessages.append(content) }
|
|
|
|
func drainPendingGeohashSystemMessages() -> [String] {
|
|
defer { pendingGeohashSystemMessages.removeAll() }
|
|
return pendingGeohashSystemMessages
|
|
}
|
|
|
|
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
|
|
guard !appendedGeohashMessages.contains(where: { $0.message.id == message.id && $0.geohash == geohash }) else {
|
|
return false
|
|
}
|
|
appendedGeohashMessages.append((message, geohash))
|
|
return true
|
|
}
|
|
|
|
func synchronizePublicConversationStore(forGeohash geohash: String) { synchronizedGeohashes.append(geohash) }
|
|
|
|
// Inbound public messages
|
|
private(set) var handledPublicMessages: [BitchatMessage] = []
|
|
private(set) var mentionCheckedMessageIDs: [String] = []
|
|
private(set) var hapticMessageIDs: [String] = []
|
|
|
|
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessages.append(message) }
|
|
func checkForMentions(_ message: BitchatMessage) { mentionCheckedMessageIDs.append(message.id) }
|
|
func sendHapticFeedback(for message: BitchatMessage) { hapticMessageIDs.append(message.id) }
|
|
func parseMentions(from content: String) -> [String] { [] }
|
|
|
|
// Inbound private (geohash DM) payloads
|
|
var selectedPrivateChatPeer: PeerID?
|
|
var nostrKeyMapping: [PeerID: String] = [:]
|
|
private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = []
|
|
private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = []
|
|
private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = []
|
|
private(set) var startedPrivateChats: [PeerID] = []
|
|
|
|
func handlePrivateMessage(
|
|
_ payload: NoisePayload,
|
|
senderPubkey: String,
|
|
convKey: PeerID,
|
|
id: NostrIdentity,
|
|
messageTimestamp: Date
|
|
) {
|
|
handledPrivateMessages.append((payload, senderPubkey, convKey, messageTimestamp))
|
|
}
|
|
|
|
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
|
handledDelivered.append((senderPubkey, convKey))
|
|
}
|
|
|
|
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
|
handledReadReceipts.append((senderPubkey, convKey))
|
|
}
|
|
|
|
func startPrivateChat(with peerID: PeerID) { startedPrivateChats.append(peerID) }
|
|
|
|
// Nostr identity & blocking
|
|
var geohashIdentities: [String: NostrIdentity] = [:]
|
|
var nostrIdentity: NostrIdentity?
|
|
var blockedNostrPubkeys: Set<String> = []
|
|
var displayNamesByPubkey: [String: String] = [:]
|
|
|
|
private struct NoIdentity: Error {}
|
|
|
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
|
guard let identity = geohashIdentities[geohash] else { throw NoIdentity() }
|
|
return identity
|
|
}
|
|
|
|
func currentNostrIdentity() -> NostrIdentity? { nostrIdentity }
|
|
|
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
|
blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased())
|
|
}
|
|
|
|
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
|
displayNamesByPubkey[pubkeyHex] ?? "anon"
|
|
}
|
|
|
|
// Event dedup
|
|
private(set) var recordedNostrEventIDs: [String] = []
|
|
private var processedNostrEventIDs: Set<String> = []
|
|
private(set) var clearProcessedNostrEventsCount = 0
|
|
|
|
func hasProcessedNostrEvent(_ eventID: String) -> Bool { processedNostrEventIDs.contains(eventID) }
|
|
|
|
func recordProcessedNostrEvent(_ eventID: String) {
|
|
processedNostrEventIDs.insert(eventID)
|
|
recordedNostrEventIDs.append(eventID)
|
|
}
|
|
|
|
func clearProcessedNostrEvents() {
|
|
processedNostrEventIDs.removeAll()
|
|
clearProcessedNostrEventsCount += 1
|
|
}
|
|
|
|
// Geo participants & presence
|
|
var geoNicknames: [String: String] = [:]
|
|
private(set) var teleportedKeys: Set<String> = []
|
|
var teleportedGeoCount: Int { teleportedKeys.count }
|
|
private(set) var refreshTimerStartCount = 0
|
|
private(set) var refreshTimerStopCount = 0
|
|
private(set) var activeParticipantGeohashes: [String?] = []
|
|
private(set) var recordedParticipants: [String] = []
|
|
private(set) var recordedSampledParticipants: [(pubkeyHex: String, geohash: String)] = []
|
|
private(set) var clearTeleportedGeoCount = 0
|
|
private(set) var clearGeoNicknamesCount = 0
|
|
var visiblePeople: [GeoPerson] = []
|
|
|
|
func startGeoParticipantRefreshTimer() { refreshTimerStartCount += 1 }
|
|
func stopGeoParticipantRefreshTimer() { refreshTimerStopCount += 1 }
|
|
func setActiveParticipantGeohash(_ geohash: String?) { activeParticipantGeohashes.append(geohash) }
|
|
func recordGeoParticipant(pubkeyHex: String) { recordedParticipants.append(pubkeyHex) }
|
|
|
|
func recordGeoParticipant(pubkeyHex: String, geohash: String) {
|
|
recordedSampledParticipants.append((pubkeyHex, geohash))
|
|
}
|
|
|
|
func geoParticipantCount(for geohash: String) -> Int {
|
|
recordedSampledParticipants.filter { $0.geohash == geohash }.count
|
|
}
|
|
|
|
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { geoNicknames[pubkeyHex.lowercased()] = nickname }
|
|
func markGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.insert(pubkeyHexLowercased) }
|
|
func clearGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.remove(pubkeyHexLowercased) }
|
|
|
|
func clearTeleportedGeo() {
|
|
teleportedKeys.removeAll()
|
|
clearTeleportedGeoCount += 1
|
|
}
|
|
|
|
func clearGeoNicknames() {
|
|
geoNicknames.removeAll()
|
|
clearGeoNicknamesCount += 1
|
|
}
|
|
|
|
func visibleGeohashPeople() -> [GeoPerson] { visiblePeople }
|
|
|
|
// Location channels
|
|
var isTeleported = false
|
|
var regionalGeohashes: Set<String> = []
|
|
|
|
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool {
|
|
!regionalGeohashes.isEmpty && !regionalGeohashes.contains(geohash)
|
|
}
|
|
|
|
// Routing & acknowledgements
|
|
private(set) var routedFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
|
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
|
|
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
|
|
|
|
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
|
routedFavoriteNotifications.append((peerID, isFavorite))
|
|
}
|
|
|
|
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
geoDeliveryAcks.append((messageID, recipientHex))
|
|
}
|
|
|
|
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
geoReadReceipts.append((messageID, recipientHex))
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Let the inner `Task { @MainActor in ... }` hops the coordinator schedules
|
|
/// run to completion.
|
|
@MainActor
|
|
private func drainMainQueue() async {
|
|
for _ in 0..<5 {
|
|
await Task.yield()
|
|
}
|
|
}
|
|
|
|
// MARK: - Coordinator Tests Against Mock Context
|
|
|
|
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
|
|
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
|
|
/// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch
|
|
/// teardown, and embedded ack flows. Flows that hit live singletons
|
|
/// (`NostrRelayManager.shared` subscriptions, `TorManager`,
|
|
/// `FavoritesPersistenceService`) remain covered by the full view-model tests.
|
|
struct ChatNostrCoordinatorContextTests {
|
|
|
|
@Test @MainActor
|
|
func handleNostrEvent_ingestsPublicMessageOnceAndDeduplicates() async throws {
|
|
let context = MockChatNostrContext()
|
|
let coordinator = ChatNostrCoordinator(context: context)
|
|
|
|
let sender = try NostrIdentity.generate()
|
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
|
content: "hello geohash",
|
|
geohash: "u4pruyd",
|
|
senderIdentity: sender,
|
|
nickname: "alice"
|
|
)
|
|
context.displayNamesByPubkey[event.pubkey] = "alice#1234"
|
|
|
|
coordinator.handleNostrEvent(event)
|
|
await drainMainQueue()
|
|
|
|
// Dedup recorded exactly once, presence and key mapping updated.
|
|
#expect(context.recordedNostrEventIDs == [event.id])
|
|
#expect(context.geoNicknames[event.pubkey.lowercased()] == "alice")
|
|
#expect(context.recordedParticipants == [event.pubkey])
|
|
#expect(context.nostrKeyMapping[PeerID(nostr: event.pubkey)] == event.pubkey)
|
|
#expect(context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] == event.pubkey)
|
|
|
|
// The message reached the public ingest path with the resolved name.
|
|
#expect(context.handledPublicMessages.map(\.id) == [event.id])
|
|
#expect(context.handledPublicMessages.first?.sender == "alice#1234")
|
|
#expect(context.handledPublicMessages.first?.content == "hello geohash")
|
|
#expect(context.mentionCheckedMessageIDs == [event.id])
|
|
#expect(context.hapticMessageIDs == [event.id])
|
|
|
|
// A replay of the same event is dropped before any processing.
|
|
coordinator.handleNostrEvent(event)
|
|
await drainMainQueue()
|
|
#expect(context.recordedNostrEventIDs == [event.id])
|
|
#expect(context.handledPublicMessages.count == 1)
|
|
#expect(context.recordedParticipants.count == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handleNostrEvent_marksTeleportedPeerWithoutIngestingEmptyContent() async throws {
|
|
let context = MockChatNostrContext()
|
|
let coordinator = ChatNostrCoordinator(context: context)
|
|
|
|
let sender = try NostrIdentity.generate()
|
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
|
content: "",
|
|
geohash: "u4pruyd",
|
|
senderIdentity: sender,
|
|
teleported: true
|
|
)
|
|
|
|
coordinator.handleNostrEvent(event)
|
|
await drainMainQueue()
|
|
|
|
// Teleport detection fires even though the empty message is dropped.
|
|
#expect(context.teleportedKeys == [event.pubkey.lowercased()])
|
|
#expect(context.recordedParticipants == [event.pubkey])
|
|
#expect(context.handledPublicMessages.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handleNostrEvent_skipsBlockedSender() async throws {
|
|
let context = MockChatNostrContext()
|
|
let coordinator = ChatNostrCoordinator(context: context)
|
|
|
|
let sender = try NostrIdentity.generate()
|
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
|
content: "spam",
|
|
geohash: "u4pruyd",
|
|
senderIdentity: sender
|
|
)
|
|
context.blockedNostrPubkeys.insert(event.pubkey.lowercased())
|
|
|
|
coordinator.handleNostrEvent(event)
|
|
await drainMainQueue()
|
|
|
|
// The event is still recorded for dedup but nothing else happens.
|
|
#expect(context.recordedNostrEventIDs == [event.id])
|
|
#expect(context.recordedParticipants.isEmpty)
|
|
#expect(context.handledPublicMessages.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
|
let context = MockChatNostrContext()
|
|
let coordinator = ChatNostrCoordinator(context: context)
|
|
|
|
let recipient = try NostrIdentity.generate()
|
|
let sender = try NostrIdentity.generate()
|
|
let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
|
content: "psst",
|
|
messageID: "gm-1",
|
|
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
|
))
|
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
|
content: embedded,
|
|
recipientPubkey: recipient.publicKeyHex,
|
|
senderIdentity: sender
|
|
)
|
|
|
|
coordinator.handleGiftWrap(giftWrap, id: recipient)
|
|
|
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
|
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
|
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
|
#expect(context.handledPrivateMessages.count == 1)
|
|
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
|
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
|
|
|
// The embedded Noise payload survives the round trip intact.
|
|
let payload = try #require(context.handledPrivateMessages.first?.payload)
|
|
#expect(payload.type == .privateMessage)
|
|
let pm = try #require(PrivateMessagePacket.decode(from: payload.data))
|
|
#expect(pm.messageID == "gm-1")
|
|
#expect(pm.content == "psst")
|
|
|
|
// The same gift wrap is dropped on replay.
|
|
coordinator.handleGiftWrap(giftWrap, id: recipient)
|
|
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
|
#expect(context.handledPrivateMessages.count == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func switchLocationChannel_toMesh_tearsDownGeohashState() async {
|
|
let context = MockChatNostrContext()
|
|
let coordinator = ChatNostrCoordinator(context: context)
|
|
context.activeChannel = .mesh
|
|
context.currentGeohash = "u4pruyd"
|
|
context.geoNicknames = ["abcd": "alice"]
|
|
|
|
coordinator.switchLocationChannel(to: .mesh)
|
|
|
|
#expect(context.pipelineResetCount == 1)
|
|
#expect(context.activeChannel == .mesh)
|
|
#expect(context.pipelineChannelUpdates == [.mesh])
|
|
#expect(context.clearProcessedNostrEventsCount == 1)
|
|
#expect(context.refreshedChannels == [.mesh])
|
|
#expect(context.refreshTimerStopCount == 1)
|
|
#expect(context.clearTeleportedGeoCount == 1)
|
|
// Cleared once in the mesh branch, once in the shared teardown.
|
|
#expect(context.activeParticipantGeohashes == [nil, nil])
|
|
#expect(context.currentGeohash == nil)
|
|
#expect(context.geoSubscriptionID == nil)
|
|
#expect(context.geoDmSubscriptionID == nil)
|
|
#expect(context.clearGeoNicknamesCount == 1)
|
|
#expect(context.geoNicknames.isEmpty)
|
|
// Mesh never starts a geohash subscription or refresh timer.
|
|
#expect(context.refreshTimerStartCount == 0)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func sendDeliveryAckViaNostrEmbedded_sendsReadReceiptOnlyWhenViewingUnread() async throws {
|
|
let context = MockChatNostrContext()
|
|
let coordinator = ChatNostrCoordinator(context: context)
|
|
context.nostrIdentity = try NostrIdentity.generate()
|
|
let senderPubkey = "feedface00112233"
|
|
let convKey = PeerID(nostr_: senderPubkey)
|
|
|
|
let message = BitchatMessage(
|
|
id: "mid-1",
|
|
sender: "alice#1234",
|
|
content: "hi",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "me",
|
|
senderPeerID: convKey
|
|
)
|
|
|
|
// Not viewing the chat: delivery ack only.
|
|
coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: false, senderPubkey: senderPubkey, key: nil)
|
|
#expect(context.geoDeliveryAcks.map(\.messageID) == ["mid-1"])
|
|
#expect(context.geoDeliveryAcks.first?.recipientHex == senderPubkey)
|
|
#expect(context.geoReadReceipts.isEmpty)
|
|
|
|
// Viewing the chat: delivery ack plus read receipt.
|
|
context.selectedPrivateChatPeer = convKey
|
|
coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: false, senderPubkey: senderPubkey, key: Data([0x01]))
|
|
#expect(context.geoDeliveryAcks.count == 2)
|
|
#expect(context.geoReadReceipts.map(\.messageID) == ["mid-1"])
|
|
|
|
// Already read: no further read receipt.
|
|
coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: true, senderPubkey: senderPubkey, key: nil)
|
|
#expect(context.geoDeliveryAcks.count == 3)
|
|
#expect(context.geoReadReceipts.count == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func geohashDMKeyMappingHelpers_resolveAndStartChats() async {
|
|
let context = MockChatNostrContext()
|
|
let coordinator = ChatNostrCoordinator(context: context)
|
|
let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
|
let convKey = PeerID(nostr_: hex)
|
|
context.displayNamesByPubkey[hex] = "bob#eeff"
|
|
|
|
coordinator.startGeohashDM(withPubkeyHex: hex)
|
|
#expect(context.nostrKeyMapping[convKey] == hex)
|
|
#expect(context.startedPrivateChats == [convKey])
|
|
|
|
#expect(coordinator.fullNostrHex(forSenderPeerID: convKey) == hex)
|
|
#expect(coordinator.geohashDisplayName(for: convKey) == "bob#eeff")
|
|
|
|
// Unmapped conversation keys fall back to the bare peer ID.
|
|
let unknown = PeerID(nostr_: "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100")
|
|
#expect(coordinator.geohashDisplayName(for: unknown) == unknown.bare)
|
|
|
|
// Display-name lookup prefers visible people, then nicknames.
|
|
context.visiblePeople = [GeoPerson(id: "aa11", displayName: "carol#aa11", lastSeen: Date())]
|
|
context.geoNicknames = ["bb22": "dave"]
|
|
#expect(coordinator.nostrPubkeyForDisplayName("carol#aa11") == "aa11")
|
|
#expect(coordinator.nostrPubkeyForDisplayName("dave") == "bb22")
|
|
#expect(coordinator.nostrPubkeyForDisplayName("nobody") == nil)
|
|
}
|
|
}
|