mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
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 <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -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<PeerID> = []
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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..<iterations {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: UUID().uuidString,
|
||||
readerID: PeerID(str: String(format: "%016x", i)),
|
||||
readerNickname: "Reader\(i)"
|
||||
)
|
||||
transport.sendReadReceipt(receipt, to: PeerID(str: String(format: "%016x", i)))
|
||||
}
|
||||
|
||||
// Give some time for processing to start
|
||||
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
||||
|
||||
// Second batch - while first might be processing
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for i in iterations..<(iterations * 2) {
|
||||
group.addTask {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: UUID().uuidString,
|
||||
readerID: PeerID(str: String(format: "%016x", i)),
|
||||
readerNickname: "Reader\(i)"
|
||||
)
|
||||
transport.sendReadReceipt(receipt, to: PeerID(str: String(format: "%016x", i)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here without crashing or deadlocking, test passes
|
||||
}
|
||||
|
||||
@Test("isPeerReachable is thread safe")
|
||||
@@ -87,10 +337,8 @@ struct NostrTransportTests {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
|
||||
let iterations = 100
|
||||
|
||||
// Concurrent reads on isPeerReachable
|
||||
await withTaskGroup(of: Bool.self) { group in
|
||||
for i in 0..<iterations {
|
||||
group.addTask {
|
||||
@@ -99,10 +347,149 @@ struct NostrTransportTests {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect results (all should be false since no favorites configured)
|
||||
for await result in group {
|
||||
#expect(result == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeDependencies(
|
||||
notificationCenter: NotificationCenter = NotificationCenter(),
|
||||
loadFavorites: @escaping @MainActor () -> [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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>()
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<V: View>(_ 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..<Int(frameCount) {
|
||||
channel[index] = sinf(Float(index) * 0.2) * 0.5
|
||||
}
|
||||
|
||||
let file = try AVAudioFile(forWriting: url, settings: format.settings)
|
||||
try file.write(from: buffer)
|
||||
return url
|
||||
}
|
||||
|
||||
private func makeTemporaryImageURL() throws -> 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<String> = []
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user