mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 05:05: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
|
// Minimal Nostr transport conforming to Transport for offline sending
|
||||||
final class NostrTransport: Transport, @unchecked Sendable {
|
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
|
// Provide BLE short peer ID for BitChat embedding
|
||||||
var senderPeerID = PeerID(str: "")
|
var senderPeerID = PeerID(str: "")
|
||||||
|
|
||||||
@@ -17,20 +43,27 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private let idBridge: NostrIdentityBridge
|
private let idBridge: NostrIdentityBridge
|
||||||
|
private let dependencies: Dependencies
|
||||||
|
private var favoriteStatusObserver: NSObjectProtocol?
|
||||||
|
|
||||||
// Reachability Cache (thread-safe)
|
// Reachability Cache (thread-safe)
|
||||||
private var reachablePeers: Set<PeerID> = []
|
private var reachablePeers: Set<PeerID> = []
|
||||||
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
init(
|
||||||
|
keychain: KeychainManagerProtocol,
|
||||||
|
idBridge: NostrIdentityBridge,
|
||||||
|
dependencies: Dependencies? = nil
|
||||||
|
) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
|
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
||||||
|
|
||||||
setupObservers()
|
setupObservers()
|
||||||
|
|
||||||
// Synchronously warm the cache to avoid startup race
|
// Synchronously warm the cache to avoid startup race
|
||||||
let favorites = FavoritesPersistenceService.shared.favorites
|
let favorites = self.dependencies.loadFavorites()
|
||||||
let reachable = favorites.values
|
let reachable = favorites.values
|
||||||
.filter { $0.peerNostrPublicKey != nil }
|
.filter { $0.peerNostrPublicKey != nil }
|
||||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
.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() {
|
private func setupObservers() {
|
||||||
NotificationCenter.default.addObserver(
|
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
||||||
forName: .favoriteStatusChanged,
|
forName: .favoriteStatusChanged,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: nil
|
queue: nil
|
||||||
@@ -52,7 +91,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
|
|
||||||
private func refreshReachablePeers() {
|
private func refreshReachablePeers() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let favorites = FavoritesPersistenceService.shared.favorites
|
let favorites = dependencies.loadFavorites()
|
||||||
let reachable = favorites.values
|
let reachable = favorites.values
|
||||||
.filter { $0.peerNostrPublicKey != nil }
|
.filter { $0.peerNostrPublicKey != nil }
|
||||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
||||||
@@ -120,7 +159,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
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)
|
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 {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
@@ -143,7 +182,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
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)"
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
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 {
|
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
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
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)
|
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 {
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
@@ -229,9 +268,9 @@ extension NostrTransport {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if registerPending {
|
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`
|
/// Must be called within a barrier on `queue`
|
||||||
@@ -249,7 +288,7 @@ extension NostrTransport {
|
|||||||
defer { scheduleNextReadAck() }
|
defer { scheduleNextReadAck() }
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
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)
|
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 {
|
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)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
@@ -260,7 +299,7 @@ extension NostrTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleNextReadAck() {
|
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?.queue.async(flags: .barrier) { [weak self] in
|
||||||
self?.isSendingReadAcks = false
|
self?.isSendingReadAcks = false
|
||||||
self?.processReadQueueIfNeeded()
|
self?.processReadQueueIfNeeded()
|
||||||
@@ -271,12 +310,12 @@ extension NostrTransport {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
||||||
if let noiseKey = Data(hexString: peerID.id),
|
if let noiseKey = Data(hexString: peerID.id),
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
let fav = dependencies.favoriteStatusForNoiseKey(noiseKey),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
if peerID.id.count == 16,
|
if peerID.id.count == 16,
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
let fav = dependencies.favoriteStatusForPeerID(peerID),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ struct LocationNotesView: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var draft: String = ""
|
@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()
|
let gh = geohash.lowercased()
|
||||||
self.geohash = gh
|
self.geohash = gh
|
||||||
self.onNotesCountChanged = onNotesCountChanged
|
self.onNotesCountChanged = onNotesCountChanged
|
||||||
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
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 Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
// MARK: - Test Helpers
|
// MARK: - Test Helpers
|
||||||
@@ -404,6 +409,274 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
|
|
||||||
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
#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
|
// MARK: - Geohash Queue Tests
|
||||||
@@ -449,6 +722,302 @@ struct ChatViewModelGeoDMTests {
|
|||||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
#expect(viewModel.privateChats[convKey]?.count == 1)
|
||||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
#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 {
|
private func base64URLEncode(_ data: Data) -> String {
|
||||||
@@ -457,3 +1026,116 @@ private func base64URLEncode(_ data: Data) -> String {
|
|||||||
.replacingOccurrences(of: "/", with: "_")
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
.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
|
@MainActor
|
||||||
struct LocationNotesManagerTests {
|
struct LocationNotesManagerTests {
|
||||||
// func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
@Test
|
||||||
// var subscribeCalled = false
|
func subscribeWithoutRelays_setsNoRelaysState() {
|
||||||
// let deps = LocationNotesDependencies(
|
var subscribeCalled = false
|
||||||
// relayLookup: { _, _ in [] },
|
let deps = LocationNotesDependencies(
|
||||||
// subscribe: { _, _, _, _, _ in
|
relayLookup: { _, _ in [] },
|
||||||
// subscribeCalled = true
|
subscribe: { _, _, _, _, _ in
|
||||||
// },
|
subscribeCalled = true
|
||||||
// unsubscribe: { _ in },
|
},
|
||||||
// sendEvent: { _, _ in },
|
unsubscribe: { _ in },
|
||||||
// deriveIdentity: { _ in fatalError("should not derive identity") },
|
sendEvent: { _, _ in },
|
||||||
// now: { Date() }
|
deriveIdentity: { _ in try NostrIdentity.generate() },
|
||||||
// )
|
now: { Date() }
|
||||||
//
|
)
|
||||||
// let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
||||||
//
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||||
// XCTAssertFalse(subscribeCalled)
|
|
||||||
// XCTAssertEqual(manager.state, .noRelays)
|
#expect(subscribeCalled == false)
|
||||||
// XCTAssertTrue(manager.initialLoadComplete)
|
#expect(manager.state == .noRelays)
|
||||||
// XCTAssertEqual(manager.errorMessage, String(localized: "location_notes.error.no_relays"))
|
#expect(manager.initialLoadComplete)
|
||||||
// // Make sure we're getting an actual translated value and not the localization key
|
#expect(manager.errorMessage == String(localized: "location_notes.error.no_relays"))
|
||||||
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
}
|
||||||
// }
|
|
||||||
//
|
@Test
|
||||||
// func testSendWhenNoRelaysSurfacesError() {
|
func sendWithoutRelays_surfacesNoRelaysError() {
|
||||||
// var sendCalled = false
|
var sendCalled = false
|
||||||
// let deps = LocationNotesDependencies(
|
let deps = LocationNotesDependencies(
|
||||||
// relayLookup: { _, _ in [] },
|
relayLookup: { _, _ in [] },
|
||||||
// subscribe: { _, _, _, _, _ in },
|
subscribe: { _, _, _, _, _ in },
|
||||||
// unsubscribe: { _ in },
|
unsubscribe: { _ in },
|
||||||
// sendEvent: { _, _ in sendCalled = true },
|
sendEvent: { _, _ in sendCalled = true },
|
||||||
// deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
||||||
// now: { Date() }
|
now: { Date() }
|
||||||
// )
|
)
|
||||||
//
|
|
||||||
// let manager = LocationNotesManager(geohash: "zzzzzzzz", dependencies: deps)
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||||
// manager.send(content: "hello", nickname: "tester")
|
manager.send(content: "hello", nickname: "tester")
|
||||||
//
|
|
||||||
// XCTAssertFalse(sendCalled)
|
#expect(sendCalled == false)
|
||||||
// XCTAssertEqual(manager.state, .noRelays)
|
#expect(manager.state == .noRelays)
|
||||||
// XCTAssertEqual(manager.errorMessage, String(localized: "location_notes.error.no_relays"))
|
#expect(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 subscribeUsesGeoRelaysAndAppendsNotes() throws {
|
@Test func subscribeUsesGeoRelaysAndAppendsNotes() throws {
|
||||||
var relaysCaptured: [String] = []
|
var relaysCaptured: [String] = []
|
||||||
@@ -88,6 +86,100 @@ struct LocationNotesManagerTests {
|
|||||||
#expect(manager.notes.first?.content == "hi")
|
#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 {
|
private enum TestError: Error {
|
||||||
case shouldNotDerive
|
case shouldNotDerive
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ final class MockTransport: Transport {
|
|||||||
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
|
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
|
||||||
private(set) var sentDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
private(set) var sentDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||||
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
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 sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var startServicesCallCount = 0
|
private(set) var startServicesCallCount = 0
|
||||||
@@ -139,15 +142,15 @@ final class MockTransport: Transport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||||
// Not tracked for current tests
|
sentBroadcastFiles.append((packet, transferId))
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||||
// Not tracked for current tests
|
sentPrivateFiles.append((packet, peerID, transferId))
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelTransfer(_ transferId: String) {
|
func cancelTransfer(_ transferId: String) {
|
||||||
// Not tracked for current tests
|
cancelledTransfers.append(transferId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||||
@@ -167,6 +170,9 @@ final class MockTransport: Transport {
|
|||||||
sentReadReceipts.removeAll()
|
sentReadReceipts.removeAll()
|
||||||
sentDeliveryAcks.removeAll()
|
sentDeliveryAcks.removeAll()
|
||||||
sentFavoriteNotifications.removeAll()
|
sentFavoriteNotifications.removeAll()
|
||||||
|
sentBroadcastFiles.removeAll()
|
||||||
|
sentPrivateFiles.removeAll()
|
||||||
|
cancelledTransfers.removeAll()
|
||||||
sentVerifyChallenges.removeAll()
|
sentVerifyChallenges.removeAll()
|
||||||
sentVerifyResponses.removeAll()
|
sentVerifyResponses.removeAll()
|
||||||
startServicesCallCount = 0
|
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
|
import Testing
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@Suite("NostrTransport Thread Safety Tests")
|
@Suite("NostrTransport Tests")
|
||||||
struct NostrTransportTests {
|
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")
|
@Test("Concurrent read receipt enqueue does not crash")
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -19,8 +314,6 @@ struct NostrTransportTests {
|
|||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||||
|
|
||||||
// Create 100 concurrent read receipt submissions
|
|
||||||
let iterations = 100
|
let iterations = 100
|
||||||
|
|
||||||
await withTaskGroup(of: Void.self) { group in
|
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")
|
@Test("isPeerReachable is thread safe")
|
||||||
@@ -87,10 +337,8 @@ struct NostrTransportTests {
|
|||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||||
|
|
||||||
let iterations = 100
|
let iterations = 100
|
||||||
|
|
||||||
// Concurrent reads on isPeerReachable
|
|
||||||
await withTaskGroup(of: Bool.self) { group in
|
await withTaskGroup(of: Bool.self) { group in
|
||||||
for i in 0..<iterations {
|
for i in 0..<iterations {
|
||||||
group.addTask {
|
group.addTask {
|
||||||
@@ -99,10 +347,149 @@ struct NostrTransportTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect results (all should be false since no favorites configured)
|
|
||||||
for await result in group {
|
for await result in group {
|
||||||
#expect(result == false)
|
#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.sentReadReceipts.contains("pm-2"))
|
||||||
#expect(!manager.unreadMessages.contains(peerID))
|
#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