Expand coverage for relay, identity, and location flows (#1055)

* Expand coverage for relay, identity, and location flows

* Fix macOS SwiftPM CI failures

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2026-03-12 12:50:46 -10:00
committed by GitHub
co-authored by jack
parent c043cf6354
commit a136b5b7e9
22 changed files with 2926 additions and 164 deletions
+6 -1
View File
@@ -89,7 +89,12 @@ private func makeService() -> BLEService {
let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain)
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
return BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
return BLEService(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
initializeBluetoothManagers: false
)
}
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
@@ -125,26 +125,20 @@ struct ChatViewModelRefactoringTests {
let (viewModel, _, _) = makePinnedViewModel()
let senderID = PeerID(str: "sender_2")
// Setup
let message = BitchatMessage(
id: "msg_2",
sender: "charlie",
// Action
viewModel.didReceivePublicMessage(
from: senderID,
nickname: "charlie",
content: "Public Hi",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: senderID,
mentions: nil
messageID: "msg_2"
)
// Action
viewModel.didReceiveMessage(message)
// Wait for async processing with proper timeout
let found = await TestHelpers.waitUntil(
{ viewModel.messages.contains(where: { $0.content == "Public Hi" }) },
{
viewModel.timelineStore.messages(for: .mesh).contains(where: { $0.content == "Public Hi" })
},
timeout: TestConstants.defaultTimeout
)
+1 -2
View File
@@ -196,8 +196,7 @@ struct ChatViewModelReceivingTests {
)
let found = await TestHelpers.waitUntil({
viewModel.publicMessagePipeline.flushIfNeeded()
return viewModel.messages.contains { $0.content == "Public hello from Bob" }
viewModel.timelineStore.messages(for: .mesh).contains { $0.content == "Public hello from Bob" }
}, timeout: TestConstants.defaultTimeout)
#expect(found)
+390 -1
View File
@@ -1,11 +1,13 @@
import Foundation
import Testing
@testable import bitchat
@Suite(.serialized)
struct CommandProcessorTests {
private var identityManager = MockIdentityManager(MockKeychain())
@MainActor
@Test func slapNotFoundGrammar() {
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap @system")
switch result {
@@ -18,6 +20,7 @@ struct CommandProcessorTests {
@MainActor
@Test func hugNotFoundGrammar() {
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/hug @system")
switch result {
@@ -30,6 +33,7 @@ struct CommandProcessorTests {
@MainActor
@Test func slapUsageMessage() {
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap")
switch result {
@@ -39,4 +43,389 @@ struct CommandProcessorTests {
Issue.record("Expected error result for usage message")
}
}
@MainActor
@Test func msgStartsPrivateChatAndSendsMessage() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let peerID = PeerID(str: "abcd1234abcd1234")
context.nicknameToPeerID["alice"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/msg @alice hello there")
}
switch result {
case .success(let message):
#expect(message == "started private chat with alice")
default:
Issue.record("Expected success result")
}
#expect(context.startedPrivateChats == [peerID])
#expect(context.sentPrivateMessages.count == 1)
#expect(context.sentPrivateMessages.first?.content == "hello there")
#expect(context.sentPrivateMessages.first?.peerID == peerID)
}
@MainActor
@Test func whoInMeshListsSortedPeerNicknames() async {
let identityManager = MockIdentityManager(MockKeychain())
let transport = MockTransport()
transport.peerNicknames = [
PeerID(str: "b"): "bob",
PeerID(str: "a"): "alice"
]
let processor = CommandProcessor(contextProvider: MockCommandContextProvider(), meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/who")
}
switch result {
case .success(let message):
#expect(message == "online: alice, bob")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func whoInGeohashListsVisibleParticipantsExcludingSelf() async throws {
let bridge = NostrIdentityBridge(keychain: MockKeychain())
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(idBridge: bridge)
let geohash = "u4pruy"
let selfPubkey = try bridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased()
context.visibleGeoParticipants = [
CommandGeoParticipant(id: selfPubkey, displayName: "me"),
CommandGeoParticipant(id: String(repeating: "b", count: 64), displayName: "bob")
]
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
let result = try await withSelectedChannel(channel) {
processor.process("/who")
}
switch result {
case .success(let message):
#expect(message == "online: bob")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func clearInPrivateChatRemovesOnlySelectedConversation() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let activePeer = PeerID(str: "active")
let otherPeer = PeerID(str: "other")
context.selectedPrivateChatPeer = activePeer
context.privateChats = [
activePeer: [makeMessage(sender: "alice", content: "secret")],
otherPeer: [makeMessage(sender: "bob", content: "keep")]
]
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/clear")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.privateChats[activePeer] == [])
#expect(context.privateChats[otherPeer]?.count == 1)
}
@MainActor
@Test func clearInPublicChatClearsTimeline() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/clear")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.clearCurrentPublicTimelineCallCount == 1)
}
@MainActor
@Test func hugInPrivateChatSendsPersonalizedMessageAndLocalEcho() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(nickname: "me")
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
context.selectedPrivateChatPeer = peerID
context.nicknameToPeerID["bob"] = peerID
transport.peerNicknames[peerID] = "Bob"
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/hug @bob")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(transport.sentPrivateMessages.count == 1)
#expect(transport.sentPrivateMessages.first?.content == "* 🫂 me hugs you *")
#expect(context.localPrivateSystemMessages.first?.content == "🫂 you hugged bob")
#expect(context.localPrivateSystemMessages.first?.peerID == peerID)
}
@MainActor
@Test func slapInPublicChatSendsPublicRawAndEcho() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(nickname: "me")
let peerID = PeerID(str: "abcd1234abcd1234")
context.nicknameToPeerID["bob"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/slap @bob")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.sentPublicRawMessages == ["* 🐟 me slaps bob around a bit with a large trout *"])
#expect(context.publicSystemMessages == ["🐟 me slaps bob around a bit with a large trout"])
}
@MainActor
@Test func blockWithoutArgsListsMeshAndGeohashBlocks() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
transport.peerNicknames[peerID] = "bob"
transport.peerFingerprints[peerID] = "fp-bob"
context.blockedUsers = ["fp-bob"]
context.visibleGeoParticipants = [
CommandGeoParticipant(id: String(repeating: "c", count: 64), displayName: "carol")
]
identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true)
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/block")
}
switch result {
case .success(let message):
#expect(message == "blocked peers: bob | geohash blocks: carol")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func blockAndUnblockMeshPeerUpdateIdentityState() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
transport.peerFingerprints[peerID] = "fp-bob"
context.nicknameToPeerID["bob"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let blockResult = await withSelectedChannel(.mesh) {
processor.process("/block @bob")
}
switch blockResult {
case .success(let message):
#expect(message == "blocked bob. you will no longer receive messages from them")
default:
Issue.record("Expected success result")
}
#expect(identityManager.isBlocked(fingerprint: "fp-bob"))
let unblockResult = await withSelectedChannel(.mesh) {
processor.process("/unblock bob")
}
switch unblockResult {
case .success(let message):
#expect(message == "unblocked bob")
default:
Issue.record("Expected success result")
}
#expect(!identityManager.isBlocked(fingerprint: "fp-bob"))
}
@MainActor
@Test func blockAndUnblockGeohashPeerUseNostrBlockList() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64)
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let blockResult = await withSelectedChannel(.mesh) {
processor.process("/block carol")
}
switch blockResult {
case .success(let message):
#expect(message == "blocked carol in geohash chats")
default:
Issue.record("Expected success result")
}
#expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
let unblockResult = await withSelectedChannel(.mesh) {
processor.process("/unblock @carol")
}
switch unblockResult {
case .success(let message):
#expect(message == "unblocked carol in geohash chats")
default:
Issue.record("Expected success result")
}
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
}
@MainActor
@Test func favoriteCommandIsRejectedOutsideMesh() async {
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(
contextProvider: MockCommandContextProvider(),
meshService: MockTransport(),
identityManager: identityManager
)
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy"))
let result = await withSelectedChannel(channel) {
processor.process("/fav alice")
}
switch result {
case .error(let message):
#expect(message == "favorites are only for mesh peers in #mesh")
default:
Issue.record("Expected error result")
}
}
@MainActor
private func withSelectedChannel<T>(_ channel: ChannelID, perform work: @escaping () throws -> T) async rethrows -> T {
let originalChannel = LocationChannelManager.shared.selectedChannel
await setSelectedChannel(channel)
do {
let result = try work()
await setSelectedChannel(originalChannel)
return result
} catch {
await setSelectedChannel(originalChannel)
throw error
}
}
@MainActor
private func setSelectedChannel(_ channel: ChannelID) async {
LocationChannelManager.shared.select(channel)
for _ in 0..<40 {
if LocationChannelManager.shared.selectedChannel == channel {
return
}
await Task.yield()
try? await Task.sleep(nanoseconds: 5_000_000)
}
}
private func makeMessage(sender: String, content: String) -> BitchatMessage {
BitchatMessage(
sender: sender,
content: content,
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
isRelay: false
)
}
}
@MainActor
private final class MockCommandContextProvider: CommandContextProvider {
var nickname: String
var selectedPrivateChatPeer: PeerID?
var blockedUsers: Set<String> = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
let idBridge: NostrIdentityBridge
var nicknameToPeerID: [String: PeerID] = [:]
var visibleGeoParticipants: [CommandGeoParticipant] = []
var displayNameToNostrPubkey: [String: String] = [:]
private(set) var startedPrivateChats: [PeerID] = []
private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = []
private(set) var clearCurrentPublicTimelineCallCount = 0
private(set) var sentPublicRawMessages: [String] = []
private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []
private(set) var publicSystemMessages: [String] = []
private(set) var toggledFavorites: [PeerID] = []
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
init(nickname: String = "tester", idBridge: NostrIdentityBridge = NostrIdentityBridge(keychain: MockKeychain())) {
self.nickname = nickname
self.idBridge = idBridge
}
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
nicknameToPeerID[nickname]
}
func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
visibleGeoParticipants
}
func nostrPubkeyForDisplayName(_ displayName: String) -> String? {
displayNameToNostrPubkey[displayName]
}
func startPrivateChat(with peerID: PeerID) {
startedPrivateChats.append(peerID)
}
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
sentPrivateMessages.append((content, peerID))
}
func clearCurrentPublicTimeline() {
clearCurrentPublicTimelineCallCount += 1
}
func sendPublicRaw(_ content: String) {
sentPublicRawMessages.append(content)
}
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
localPrivateSystemMessages.append((content, peerID))
}
func addPublicSystemMessage(_ content: String) {
publicSystemMessages.append(content)
}
func toggleFavorite(peerID: PeerID) {
toggledFavorites.append(peerID)
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
favoriteNotifications.append((peerID, isFavorite))
}
}
@@ -28,7 +28,8 @@ struct FragmentationTests {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
identityManager: mockIdentityManager,
initializeBluetoothManagers: false
)
let capture = CaptureDelegate()
ble.delegate = capture
@@ -63,7 +64,8 @@ struct FragmentationTests {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
identityManager: mockIdentityManager,
initializeBluetoothManagers: false
)
let capture = CaptureDelegate()
ble.delegate = capture
@@ -97,7 +99,8 @@ struct FragmentationTests {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
identityManager: mockIdentityManager,
initializeBluetoothManagers: false
)
let capture = CaptureDelegate()
ble.delegate = capture
@@ -153,7 +156,8 @@ struct FragmentationTests {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
identityManager: mockIdentityManager,
initializeBluetoothManagers: false
)
let capture = CaptureDelegate()
ble.delegate = capture
@@ -12,6 +12,108 @@ import SwiftUI
@testable import bitchat
struct MessageFormattingEngineTests {
// MARK: - Formatting Behavior Tests
@MainActor
@Test func formatMessage_regularMessageFormatsHeaderContentAndTimestamp() {
let senderPeerID = PeerID(str: "abcdef1234567890")
let context = MockMessageFormattingContext(
nickname: "carol",
peerURLs: [senderPeerID: URL(string: "https://example.com/peers/alice")!]
)
let message = BitchatMessage(
id: "message-1",
sender: "alice#a1b2",
content: "hello #mesh https://example.com",
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
isRelay: false,
senderPeerID: senderPeerID
)
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
#expect(String(formatted.characters) == "<@alice#a1b2> hello #mesh https://example.com [\(message.formattedTimestamp)]")
#expect(message.getCachedFormattedText(isDark: false, isSelf: false) != nil)
}
@MainActor
@Test func formatMessage_systemMessageUsesSystemLayout() {
let context = MockMessageFormattingContext(nickname: "carol")
let message = BitchatMessage(
id: "system-1",
sender: "system",
content: "connected",
timestamp: Date(timeIntervalSince1970: 1_700_000_123),
isRelay: false
)
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .dark)
#expect(String(formatted.characters) == "* connected * [\(message.formattedTimestamp)]")
#expect(message.getCachedFormattedText(isDark: true, isSelf: false) != nil)
}
@MainActor
@Test func formatMessage_longSelfMessageFallsBackToPlainContentPath() {
let context = MockMessageFormattingContext(
nickname: "me",
selfMessageIDs: ["self-1"]
)
let longContent = String(repeating: "a", count: 4_500)
let message = BitchatMessage(
id: "self-1",
sender: "me#cafe",
content: longContent,
timestamp: Date(timeIntervalSince1970: 1_700_000_456),
isRelay: false
)
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
#expect(String(formatted.characters) == "<@me#cafe> \(longContent) [\(message.formattedTimestamp)]")
#expect(message.getCachedFormattedText(isDark: false, isSelf: true) != nil)
}
@MainActor
@Test func formatMessage_mentionsAreRenderedThroughMentionFormatter() {
let context = MockMessageFormattingContext(nickname: "carol")
let message = BitchatMessage(
id: "message-mention",
sender: "alice",
content: "hi @bob#a1b2",
timestamp: Date(timeIntervalSince1970: 1_700_000_789),
isRelay: false
)
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
#expect(String(formatted.characters) == "<@alice> hi bob#a1b2 [\(message.formattedTimestamp)]")
}
@MainActor
@Test func formatHeader_formatsNormalAndSystemSenders() {
let context = MockMessageFormattingContext(nickname: "carol")
let normalMessage = BitchatMessage(
id: "header-1",
sender: "alice#a1b2",
content: "hello",
timestamp: Date(timeIntervalSince1970: 1_700_001_000),
isRelay: false
)
let systemMessage = BitchatMessage(
id: "header-2",
sender: "system",
content: "notice",
timestamp: Date(timeIntervalSince1970: 1_700_001_111),
isRelay: false
)
let normalHeader = MessageFormattingEngine.formatHeader(normalMessage, context: context, colorScheme: .light)
let systemHeader = MessageFormattingEngine.formatHeader(systemMessage, context: context, colorScheme: .dark)
#expect(String(normalHeader.characters) == "<@alice#a1b2> ")
#expect(String(systemHeader.characters) == "system")
}
// MARK: - Mention Extraction Tests
@@ -221,3 +323,32 @@ struct MessageFormattingEngineTests {
#expect(content.hasVeryLongToken(threshold: 50))
}
}
@MainActor
private final class MockMessageFormattingContext: MessageFormattingContext {
let nickname: String
private let selfMessageIDs: Set<String>
private let peerURLs: [PeerID: URL]
init(
nickname: String,
selfMessageIDs: Set<String> = [],
peerURLs: [PeerID: URL] = [:]
) {
self.nickname = nickname
self.selfMessageIDs = selfMessageIDs
self.peerURLs = peerURLs
}
func isSelfMessage(_ message: BitchatMessage) -> Bool {
selfMessageIDs.contains(message.id)
}
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color {
.red
}
func peerURL(for peerID: PeerID) -> URL? {
peerURLs[peerID]
}
}
@@ -0,0 +1,69 @@
import XCTest
@testable import bitchat
final class NoiseRateLimiterTests: XCTestCase {
func test_allowHandshake_blocksAfterPerPeerLimit() {
let limiter = NoiseRateLimiter()
let peerID = makePeerID(1)
for _ in 0..<NoiseSecurityConstants.maxHandshakesPerMinute {
XCTAssertTrue(limiter.allowHandshake(from: peerID))
}
XCTAssertFalse(limiter.allowHandshake(from: peerID))
}
func test_allowHandshake_blocksAfterGlobalLimitAcrossPeers() {
let limiter = NoiseRateLimiter()
for index in 0..<NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
XCTAssertTrue(limiter.allowHandshake(from: makePeerID(index)))
}
XCTAssertFalse(limiter.allowHandshake(from: makePeerID(10_000)))
}
func test_reset_clearsPerPeerHandshakeLimit() async {
let limiter = NoiseRateLimiter()
let peerID = makePeerID(7)
for _ in 0..<NoiseSecurityConstants.maxHandshakesPerMinute {
XCTAssertTrue(limiter.allowHandshake(from: peerID))
}
XCTAssertFalse(limiter.allowHandshake(from: peerID))
limiter.reset(for: peerID)
try? await Task.sleep(nanoseconds: 50_000_000)
XCTAssertTrue(limiter.allowHandshake(from: peerID))
}
func test_allowMessage_blocksAfterPerPeerLimit() {
let limiter = NoiseRateLimiter()
let peerID = makePeerID(9)
for _ in 0..<NoiseSecurityConstants.maxMessagesPerSecond {
XCTAssertTrue(limiter.allowMessage(from: peerID))
}
XCTAssertFalse(limiter.allowMessage(from: peerID))
}
func test_resetAll_clearsGlobalHandshakeLimit() async {
let limiter = NoiseRateLimiter()
for index in 0..<NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
XCTAssertTrue(limiter.allowHandshake(from: makePeerID(index)))
}
XCTAssertFalse(limiter.allowHandshake(from: makePeerID(20_000)))
limiter.resetAll()
try? await Task.sleep(nanoseconds: 50_000_000)
XCTAssertTrue(limiter.allowHandshake(from: makePeerID(20_001)))
}
private func makePeerID(_ value: Int) -> PeerID {
PeerID(str: String(format: "%016x", value))
}
}
@@ -0,0 +1,72 @@
import Foundation
import XCTest
@testable import bitchat
final class BinaryEncodingUtilsTests: XCTestCase {
func test_appendAndReadPrimitiveValues_roundTrip() throws {
var data = Data()
data.appendUInt8(0x12)
data.appendUInt16(0x3456)
data.appendUInt32(0x789ABCDE)
data.appendUInt64(0x0123456789ABCDEF)
var offset = 0
XCTAssertEqual(data.readUInt8(at: &offset), 0x12)
XCTAssertEqual(data.readUInt16(at: &offset), 0x3456)
XCTAssertEqual(data.readUInt32(at: &offset), 0x789ABCDE)
XCTAssertEqual(data.readUInt64(at: &offset), 0x0123456789ABCDEF)
XCTAssertEqual(offset, data.count)
}
func test_appendAndReadStringDataAndDate_roundTrip() throws {
let expectedDate = Date(timeIntervalSince1970: 1_700_000_000.123)
let expectedPayload = Data([0xAA, 0xBB, 0xCC, 0xDD])
var data = Data()
data.appendString("hello")
data.appendData(expectedPayload)
data.appendDate(expectedDate)
var offset = 0
XCTAssertEqual(data.readString(at: &offset), "hello")
XCTAssertEqual(data.readData(at: &offset), expectedPayload)
let decodedDate = try XCTUnwrap(data.readDate(at: &offset))
XCTAssertEqual(decodedDate.timeIntervalSince1970, expectedDate.timeIntervalSince1970, accuracy: 0.001)
}
func test_appendUUID_and_readUUID_roundTrip() throws {
let uuid = "12345678-90ab-cdef-1234-567890abcdef"
var data = Data()
data.appendUUID(uuid)
var offset = 0
XCTAssertEqual(data.readUUID(at: &offset), uuid.uppercased())
}
func test_appendStringAndData_truncateToConfiguredMaxLength() throws {
var data = Data()
data.appendString("abcdef", maxLength: 4)
data.appendData(Data([1, 2, 3, 4, 5]), maxLength: 3)
var offset = 0
XCTAssertEqual(data.readString(at: &offset), "abcd")
XCTAssertEqual(data.readData(at: &offset, maxLength: 3), Data([1, 2, 3]))
}
func test_readMethods_returnNilWhenOutOfBounds() {
var offset = 0
let shortData = Data([0x01])
XCTAssertNil(shortData.readUInt16(at: &offset))
XCTAssertEqual(offset, 0)
offset = 0
XCTAssertNil(shortData.readString(at: &offset))
XCTAssertEqual(offset, 1)
offset = 0
XCTAssertNil(shortData.readFixedBytes(at: &offset, count: 2))
XCTAssertEqual(offset, 0)
}
}
@@ -0,0 +1,97 @@
import XCTest
@testable import bitchat
@MainActor
final class FavoritesPersistenceServiceTests: XCTestCase {
private let storageKey = "chat.bitchat.favorites"
private let serviceKey = "chat.bitchat.favorites"
func test_addFavorite_persistsAndPostsNotification() throws {
let keychain = MockKeychain()
let service = FavoritesPersistenceService(keychain: keychain)
let peerKey = Data((0..<32).map(UInt8.init))
let expectation = expectation(forNotification: .favoriteStatusChanged, object: nil)
service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice")
wait(for: [expectation], timeout: 1.0)
XCTAssertTrue(service.isFavorite(peerKey))
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Alice")
XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey))
}
func test_removeFavorite_preservesRelationshipWhenPeerStillFavoritesUs() {
let service = FavoritesPersistenceService(keychain: MockKeychain())
let peerKey = Data((32..<64).map(UInt8.init))
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Bob")
service.addFavorite(peerNoisePublicKey: peerKey, peerNickname: "Bob")
service.removeFavorite(peerNoisePublicKey: peerKey)
let relationship = service.getFavoriteStatus(for: peerKey)
XCTAssertNotNil(relationship)
XCTAssertEqual(relationship?.peerNickname, "Bob")
XCTAssertFalse(relationship?.isFavorite ?? true)
XCTAssertTrue(relationship?.theyFavoritedUs ?? false)
}
func test_updatePeerFavoritedUs_removesRelationshipWhenNeitherSideFavorites() {
let service = FavoritesPersistenceService(keychain: MockKeychain())
let peerKey = Data((64..<96).map(UInt8.init))
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Carol")
XCTAssertNotNil(service.getFavoriteStatus(for: peerKey))
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: false, peerNickname: "Carol")
XCTAssertNil(service.getFavoriteStatus(for: peerKey))
XCTAssertFalse(service.isMutualFavorite(peerKey))
}
func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() {
let service = FavoritesPersistenceService(keychain: MockKeychain())
let peerKey = Data((96..<128).map(UInt8.init))
service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1dan", peerNickname: "Dan")
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Dan")
let relationship = service.getFavoriteStatus(forPeerID: PeerID(publicKey: peerKey))
XCTAssertEqual(relationship?.peerNickname, "Dan")
XCTAssertTrue(service.isMutualFavorite(peerKey))
}
func test_init_deduplicatesPersistedRelationshipsByPublicKey() throws {
let keychain = MockKeychain()
let peerKey = Data((128..<160).map(UInt8.init))
let older = FavoritesPersistenceService.FavoriteRelationship(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: nil,
peerNickname: "Older",
isFavorite: true,
theyFavoritedUs: false,
favoritedAt: Date(timeIntervalSince1970: 100),
lastUpdated: Date(timeIntervalSince1970: 100)
)
let newer = FavoritesPersistenceService.FavoriteRelationship(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: "npub1newer",
peerNickname: "Newer",
isFavorite: true,
theyFavoritedUs: true,
favoritedAt: Date(timeIntervalSince1970: 100),
lastUpdated: Date(timeIntervalSince1970: 200)
)
let encoded = try JSONEncoder().encode([older, newer])
keychain.save(key: storageKey, data: encoded, service: serviceKey, accessible: nil)
let service = FavoritesPersistenceService(keychain: keychain)
XCTAssertEqual(service.favorites.count, 1)
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Newer")
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNostrPublicKey, "npub1newer")
let cleaned = try XCTUnwrap(keychain.load(key: storageKey, service: serviceKey))
let decoded = try JSONDecoder().decode([FavoritesPersistenceService.FavoriteRelationship].self, from: cleaned)
XCTAssertEqual(decoded.count, 1)
}
}
@@ -0,0 +1,237 @@
import Combine
import XCTest
@testable import bitchat
@MainActor
final class GeohashPresenceServiceTests: XCTestCase {
func test_start_schedulesHeartbeatUsingConfiguredInterval() {
let scheduler = MockGeohashPresenceScheduler()
let service = makeService(scheduler: scheduler, loopMinInterval: 42, loopMaxInterval: 42)
service.start()
XCTAssertEqual(scheduler.intervals, [42])
}
func test_handleLocationChange_invalidatesExistingTimerAndSchedulesQuickRefresh() {
let scheduler = MockGeohashPresenceScheduler()
let service = makeService(scheduler: scheduler, loopMinInterval: 40, loopMaxInterval: 40)
service.start()
let originalTimer = scheduler.timers.first
service.handleLocationChange()
XCTAssertEqual(scheduler.intervals, [40, 5])
XCTAssertEqual(originalTimer?.invalidateCallCount, 1)
}
func test_handleConnectivityChange_onlySchedulesWhenExistingTimerIsMissingOrInvalid() {
let scheduler = MockGeohashPresenceScheduler()
let service = makeService(scheduler: scheduler, loopMinInterval: 33, loopMaxInterval: 33)
service.start()
service.handleConnectivityChange()
XCTAssertEqual(scheduler.intervals, [33])
scheduler.timers.last?.invalidate()
service.handleConnectivityChange()
XCTAssertEqual(scheduler.intervals, [33, 33])
}
func test_performHeartbeat_broadcastsOnlyAllowedPrecisionChannels() async throws {
let identity = try NostrIdentity.generate()
let scheduler = MockGeohashPresenceScheduler()
var sentGeohashes: [String] = []
var lookedUpGeohashes: [String] = []
var sleptNanoseconds: [UInt64] = []
let channels = [
GeohashChannel(level: .region, geohash: "9q"),
GeohashChannel(level: .province, geohash: "9q8y"),
GeohashChannel(level: .city, geohash: "9q8yy"),
GeohashChannel(level: .neighborhood, geohash: "9q8yyk"),
GeohashChannel(level: .block, geohash: "9q8yyk8"),
GeohashChannel(level: .building, geohash: "9q8yyk8y")
]
let service = makeService(
scheduler: scheduler,
availableChannels: channels,
deriveIdentity: { _ in identity },
relayLookup: { geohash, _ in
lookedUpGeohashes.append(geohash)
return ["wss://\(geohash).example"]
},
relaySender: { event, _ in
let geohash = event.tags.first(where: { $0.first == "g" })?[1]
if let geohash {
sentGeohashes.append(geohash)
}
},
sleeper: { nanoseconds in
sleptNanoseconds.append(nanoseconds)
},
loopMinInterval: 17,
loopMaxInterval: 17,
burstMinDelay: 0,
burstMaxDelay: 0
)
service.performHeartbeat()
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
XCTAssertTrue(sentAllAllowedChannels)
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
XCTAssertEqual(sleptNanoseconds.count, 3)
XCTAssertEqual(scheduler.intervals, [17])
}
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
let scheduler = MockGeohashPresenceScheduler()
var sendCount = 0
let service = makeService(
scheduler: scheduler,
torIsReady: { false },
relaySender: { _, _ in sendCount += 1 },
loopMinInterval: 21,
loopMaxInterval: 21
)
service.performHeartbeat()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(sendCount, 0)
XCTAssertEqual(scheduler.intervals, [21])
}
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
let scheduler = MockGeohashPresenceScheduler()
var sendCount = 0
let service = makeService(
scheduler: scheduler,
torIsForeground: { false },
relaySender: { _, _ in sendCount += 1 },
loopMinInterval: 22,
loopMaxInterval: 22
)
service.performHeartbeat()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(sendCount, 0)
XCTAssertEqual(scheduler.intervals, [22])
}
func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {
let identity = try NostrIdentity.generate()
var sendCount = 0
let service = makeService(
scheduler: MockGeohashPresenceScheduler(),
deriveIdentity: { _ in identity },
relayLookup: { _, _ in [] },
relaySender: { _, _ in sendCount += 1 }
)
service.broadcastPresence(for: "9q8yy")
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(sendCount, 0)
}
func test_broadcastPresence_skipsSendWhenIdentityDerivationFails() async {
enum PresenceError: Error { case failed }
var sendCount = 0
let service = makeService(
scheduler: MockGeohashPresenceScheduler(),
deriveIdentity: { _ in throw PresenceError.failed },
relaySender: { _, _ in sendCount += 1 }
)
service.broadcastPresence(for: "9q8yy")
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(sendCount, 0)
}
private func makeService(
scheduler: MockGeohashPresenceScheduler,
availableChannels: [GeohashChannel] = [
GeohashChannel(level: .city, geohash: "9q8yy")
],
torIsReady: @escaping () -> Bool = { true },
torIsForeground: @escaping () -> Bool = { true },
deriveIdentity: @escaping (String) throws -> NostrIdentity = { _ in try NostrIdentity.generate() },
relayLookup: @escaping (String, Int) -> [String] = { geohash, _ in ["wss://\(geohash).example"] },
relaySender: @escaping (NostrEvent, [String]) -> Void = { _, _ in },
sleeper: @escaping (UInt64) async -> Void = { _ in },
loopMinInterval: TimeInterval = 40,
loopMaxInterval: TimeInterval = 40,
burstMinDelay: TimeInterval = 0,
burstMaxDelay: TimeInterval = 0
) -> GeohashPresenceService {
let locationSubject = PassthroughSubject<[GeohashChannel], Never>()
let torReadySubject = PassthroughSubject<Void, Never>()
return GeohashPresenceService(
availableChannelsProvider: { availableChannels },
locationChanges: locationSubject.eraseToAnyPublisher(),
torReadyPublisher: torReadySubject.eraseToAnyPublisher(),
torIsReady: torIsReady,
torIsForeground: torIsForeground,
deriveIdentity: deriveIdentity,
relayLookup: relayLookup,
relaySender: relaySender,
sleeper: sleeper,
scheduleTimer: scheduler.schedule(interval:handler:),
loopMinInterval: loopMinInterval,
loopMaxInterval: loopMaxInterval,
burstMinDelay: burstMinDelay,
burstMaxDelay: burstMaxDelay
)
}
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping @MainActor () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return true
}
try? await Task.sleep(nanoseconds: 10_000_000)
}
return condition()
}
}
private final class MockGeohashPresenceScheduler {
private(set) var intervals: [TimeInterval] = []
private(set) var timers: [MockGeohashPresenceTimer] = []
func schedule(interval: TimeInterval, handler: @escaping () -> Void) -> GeohashPresenceTimerProtocol {
intervals.append(interval)
let timer = MockGeohashPresenceTimer(handler: handler)
timers.append(timer)
return timer
}
}
private final class MockGeohashPresenceTimer: GeohashPresenceTimerProtocol {
private let handler: () -> Void
private(set) var isValid = true
private(set) var invalidateCallCount = 0
init(handler: @escaping () -> Void) {
self.handler = handler
}
func invalidate() {
invalidateCallCount += 1
isValid = false
}
func fire() {
handler()
}
}
@@ -0,0 +1,304 @@
import CoreLocation
import MapKit
import XCTest
@testable import bitchat
@MainActor
final class LocationStateManagerTests: XCTestCase {
func test_loadPersistedState_normalizesBookmarksAndRestoresTeleportedSelection() async throws {
let storage = makeStorage()
let selected = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pru"))
storage.set(try JSONEncoder().encode(selected), forKey: "locationChannel.selected")
storage.set(try JSONEncoder().encode(["u4pru"]), forKey: "locationChannel.teleportedSet")
storage.set(try JSONEncoder().encode(["#U4PRU", "u4pru", ""]), forKey: "locationChannel.bookmarks")
let manager = LocationStateManager(
storage: storage,
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
let deniedLoaded = await waitUntil { manager.permissionState == .denied }
XCTAssertTrue(deniedLoaded)
XCTAssertEqual(manager.bookmarks, ["u4pru"])
XCTAssertEqual(manager.selectedChannel, selected)
let teleportedLoaded = await waitUntil { manager.teleported }
XCTAssertTrue(teleportedLoaded)
}
func test_enableLocationChannels_requestsAuthorizationWhenStatusIsUndetermined() {
let locationManager = MockLocationManager(authorizationStatus: .notDetermined)
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: locationManager,
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
manager.enableLocationChannels()
XCTAssertEqual(locationManager.requestAuthorizationCallCount, 1)
XCTAssertEqual(locationManager.requestLocationCallCount, 0)
}
func test_enableLocationChannels_requestsOneShotLocationWhenAuthorized() async {
let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways)
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: locationManager,
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
let authorizedLoaded = await waitUntil { manager.permissionState == .authorized }
XCTAssertTrue(authorizedLoaded)
manager.enableLocationChannels()
XCTAssertEqual(locationManager.requestLocationCallCount, 1)
XCTAssertEqual(manager.permissionState, .authorized)
}
func test_beginAndEndLiveRefresh_adjustLocationManagerMode() async {
let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways)
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: locationManager,
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
let authorizedLoaded = await waitUntil { manager.permissionState == .authorized }
XCTAssertTrue(authorizedLoaded)
manager.beginLiveRefresh()
XCTAssertEqual(locationManager.startUpdatingLocationCallCount, 1)
XCTAssertEqual(locationManager.requestLocationCallCount, 1)
XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyNearestTenMeters)
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterLiveMeters)
manager.endLiveRefresh()
XCTAssertEqual(locationManager.stopUpdatingLocationCallCount, 1)
XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyHundredMeters)
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters)
}
func test_didUpdateLocations_computesChannelsAndReverseGeocodesFriendlyNames() async {
let geocoder = MockLocationGeocoder()
geocoder.enqueue(
placemarks: [
makePlacemark(
country: "United States",
administrativeArea: "Hawaii",
locality: "Honolulu",
subLocality: "Waikiki",
name: "Hilton Hawaiian Village"
)
]
)
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: MockLocationManager(authorizationStatus: .authorizedAlways),
geocoder: geocoder,
shouldInitializeCoreLocation: true
)
let location = CLLocation(latitude: 21.2850, longitude: -157.8357)
manager.locationManager(CLLocationManager(), didUpdateLocations: [location])
let channelsAndNamesLoaded = await waitUntil {
manager.availableChannels.count == GeohashChannelLevel.allCases.count &&
manager.locationNames[.city] == "Honolulu" &&
manager.locationNames[.building] == "Hilton Hawaiian Village"
}
XCTAssertTrue(channelsAndNamesLoaded)
XCTAssertEqual(geocoder.cancelCallCount, 1)
XCTAssertEqual(geocoder.reverseRequests.count, 1)
XCTAssertEqual(manager.availableChannels.map(\.geohash.count), GeohashChannelLevel.allCases.map(\.precision))
XCTAssertEqual(manager.locationNames[.region], "United States")
XCTAssertEqual(manager.locationNames[.province], "Hawaii")
XCTAssertEqual(manager.locationNames[.city], "Honolulu")
XCTAssertEqual(manager.locationNames[.neighborhood], "Waikiki")
XCTAssertEqual(manager.locationNames[.block], "Waikiki")
XCTAssertEqual(manager.locationNames[.building], "Hilton Hawaiian Village")
}
func test_selectingInRegionChannel_clearsTeleportedPersistence() async {
let storage = makeStorage()
let manager = LocationStateManager(
storage: storage,
locationManager: MockLocationManager(authorizationStatus: .authorizedAlways),
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
let coordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
let cityGeohash = Geohash.encode(
latitude: coordinate.latitude,
longitude: coordinate.longitude,
precision: GeohashChannelLevel.city.precision
)
let channel = GeohashChannel(level: .city, geohash: cityGeohash)
manager.locationManager(CLLocationManager(), didUpdateLocations: [CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)])
let channelAvailable = await waitUntil { manager.availableChannels.contains(channel) }
XCTAssertTrue(channelAvailable)
manager.markTeleported(for: cityGeohash, true)
manager.select(.location(channel))
let selectionSettled = await waitUntil {
manager.selectedChannel == .location(channel) && !manager.teleported
}
XCTAssertTrue(selectionSettled)
let reloaded = LocationStateManager(
storage: storage,
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
let reloadedDenied = await waitUntil { reloaded.permissionState == .denied }
XCTAssertTrue(reloadedDenied)
XCTAssertEqual(reloaded.selectedChannel, .location(channel))
XCTAssertFalse(reloaded.teleported)
}
func test_addBookmark_lowPrecisionResolvesCompositeAdminName() async {
let geocoder = MockLocationGeocoder()
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")])
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")])
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")])
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Arizona")])
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")])
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: geocoder,
shouldInitializeCoreLocation: false
)
manager.addBookmark("9q")
let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "California and Nevada" }
XCTAssertTrue(bookmarkResolved)
XCTAssertEqual(geocoder.reverseRequests.count, 5)
XCTAssertEqual(manager.bookmarks, ["9q"])
}
private func makeStorage() -> UserDefaults {
let suiteName = "LocationStateManagerTests-\(UUID().uuidString)"
let storage = UserDefaults(suiteName: suiteName)!
storage.removePersistentDomain(forName: suiteName)
addTeardownBlock {
storage.removePersistentDomain(forName: suiteName)
}
return storage
}
private func makePlacemark(
country: String? = nil,
administrativeArea: String? = nil,
locality: String? = nil,
subLocality: String? = nil,
name: String? = nil
) -> CLPlacemark {
var address: [String: Any] = [:]
if let country {
address["Country"] = country
}
if let administrativeArea {
address["State"] = administrativeArea
}
if let locality {
address["City"] = locality
}
if let subLocality {
address["SubLocality"] = subLocality
}
if let name {
address["Name"] = name
}
let placemark = MKPlacemark(
coordinate: CLLocationCoordinate2D(latitude: 21.2850, longitude: -157.8357),
addressDictionary: address
)
return CLPlacemark(placemark: placemark)
}
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping @MainActor () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return true
}
try? await Task.sleep(nanoseconds: 10_000_000)
}
return condition()
}
}
private final class MockLocationManager: LocationStateManaging {
weak var delegate: CLLocationManagerDelegate?
var desiredAccuracy: CLLocationAccuracy = 0
var distanceFilter: CLLocationDistance = 0
var authorizationStatus: CLAuthorizationStatus
private(set) var requestAuthorizationCallCount = 0
private(set) var requestLocationCallCount = 0
private(set) var startUpdatingLocationCallCount = 0
private(set) var stopUpdatingLocationCallCount = 0
init(authorizationStatus: CLAuthorizationStatus) {
self.authorizationStatus = authorizationStatus
}
func requestWhenInUseAuthorization() {
requestAuthorizationCallCount += 1
}
func requestLocation() {
requestLocationCallCount += 1
}
func startUpdatingLocation() {
startUpdatingLocationCallCount += 1
}
func stopUpdatingLocation() {
stopUpdatingLocationCallCount += 1
}
}
private final class MockLocationGeocoder: LocationStateGeocoding {
private struct Response {
let placemarks: [CLPlacemark]?
let error: Error?
}
private(set) var cancelCallCount = 0
private(set) var reverseRequests: [CLLocation] = []
private var responses: [Response] = []
func enqueue(placemarks: [CLPlacemark]?, error: Error? = nil) {
responses.append(Response(placemarks: placemarks, error: error))
}
func cancelGeocode() {
cancelCallCount += 1
}
func reverseGeocodeLocation(
_ location: CLLocation,
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
) {
reverseRequests.append(location)
let response = responses.isEmpty ? Response(placemarks: nil, error: nil) : responses.removeFirst()
completionHandler(response.placemarks, response.error)
}
}
@@ -0,0 +1,197 @@
import Combine
import XCTest
@testable import bitchat
@MainActor
final class NetworkActivationServiceTests: XCTestCase {
private let torPreferenceKey = "networkActivationService.userTorEnabled"
func test_start_leavesNetworkDisabledWithoutPermissionOrFavorites() {
let context = makeService(permission: .denied, favorites: [])
context.service.start()
XCTAssertFalse(context.service.activationAllowed)
XCTAssertEqual(context.torController.autoStartAllowedValues, [false])
XCTAssertEqual(context.proxyController.proxyModes, [false])
XCTAssertEqual(context.torController.startIfNeededCallCount, 0)
XCTAssertEqual(context.torController.shutdownCompletelyCallCount, 1)
XCTAssertEqual(context.relayController.connectCallCount, 0)
XCTAssertEqual(context.relayController.disconnectCallCount, 1)
}
func test_start_enablesTorAndRelaysWhenAuthorized() {
let context = makeService(permission: .authorized, favorites: [])
context.service.start()
XCTAssertTrue(context.service.activationAllowed)
XCTAssertEqual(context.torController.autoStartAllowedValues, [true])
XCTAssertEqual(context.proxyController.proxyModes, [true])
XCTAssertEqual(context.torController.startIfNeededCallCount, 1)
XCTAssertEqual(context.relayController.connectCallCount, 1)
XCTAssertEqual(context.relayController.disconnectCallCount, 0)
}
func test_start_respectsStoredTorPreferenceForDirectMode() {
let context = makeService(permission: .authorized, favorites: [])
context.storage.set(false, forKey: torPreferenceKey)
context.service.start()
XCTAssertTrue(context.service.activationAllowed)
XCTAssertFalse(context.service.userTorEnabled)
XCTAssertEqual(context.torController.autoStartAllowedValues, [false])
XCTAssertEqual(context.proxyController.proxyModes, [false])
XCTAssertEqual(context.torController.startIfNeededCallCount, 0)
XCTAssertEqual(context.torController.shutdownCompletelyCallCount, 1)
XCTAssertEqual(context.relayController.connectCallCount, 1)
}
func test_setUserTorEnabled_postsNotificationAndReconnectsOnTransportSwitch() {
let context = makeService(permission: .authorized, favorites: [])
let notified = expectation(description: "Tor preference notification")
let token = context.notificationCenter.addObserver(
forName: .TorUserPreferenceChanged,
object: nil,
queue: nil
) { note in
XCTAssertEqual(note.userInfo?["enabled"] as? Bool, false)
notified.fulfill()
}
context.service.start()
context.service.setUserTorEnabled(false)
wait(for: [notified], timeout: 1.0)
context.notificationCenter.removeObserver(token)
XCTAssertFalse(context.service.userTorEnabled)
XCTAssertEqual(context.storage.object(forKey: torPreferenceKey) as? Bool, false)
XCTAssertEqual(Array(context.proxyController.proxyModes.suffix(2)), [true, false])
XCTAssertEqual(Array(context.torController.autoStartAllowedValues.suffix(2)), [true, false])
XCTAssertEqual(context.relayController.disconnectCallCount, 1)
XCTAssertEqual(context.relayController.connectCallCount, 2)
}
func test_mutualFavoritesPublisher_reactivatesNetwork() async {
let context = makeService(permission: .denied, favorites: [])
context.service.start()
XCTAssertFalse(context.service.activationAllowed)
context.favoritesSubject.send([Data([0x01])])
let becameActive = await waitUntil { context.service.activationAllowed }
XCTAssertTrue(becameActive)
XCTAssertTrue(context.service.activationAllowed)
XCTAssertTrue(context.torController.autoStartAllowedValues.contains(true))
XCTAssertTrue(context.proxyController.proxyModes.contains(true))
XCTAssertGreaterThanOrEqual(context.torController.startIfNeededCallCount, 1)
XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)
}
private func makeService(
permission: LocationChannelManager.PermissionState,
favorites: Set<Data>
) -> NetworkActivationTestContext {
let suiteName = "NetworkActivationServiceTests-\(UUID().uuidString)"
let storage = UserDefaults(suiteName: suiteName)!
storage.removePersistentDomain(forName: suiteName)
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)
let torController = MockNetworkActivationTorController()
let relayController = MockNetworkActivationRelayController()
let proxyController = MockNetworkActivationProxyController()
let notificationCenter = NotificationCenter()
let service = NetworkActivationService(
storage: storage,
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
permissionProvider: { permissionSubject.value },
mutualFavoritesProvider: { favoritesSubject.value },
torController: torController,
relayController: relayController,
proxyController: proxyController,
notificationCenter: notificationCenter
)
return NetworkActivationTestContext(
service: service,
storage: storage,
permissionSubject: permissionSubject,
favoritesSubject: favoritesSubject,
torController: torController,
relayController: relayController,
proxyController: proxyController,
notificationCenter: notificationCenter
)
}
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping @MainActor () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return true
}
try? await Task.sleep(nanoseconds: 10_000_000)
}
return condition()
}
}
@MainActor
private struct NetworkActivationTestContext {
let service: NetworkActivationService
let storage: UserDefaults
let permissionSubject: CurrentValueSubject<LocationChannelManager.PermissionState, Never>
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
let torController: MockNetworkActivationTorController
let relayController: MockNetworkActivationRelayController
let proxyController: MockNetworkActivationProxyController
let notificationCenter: NotificationCenter
}
@MainActor
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
private(set) var autoStartAllowedValues: [Bool] = []
private(set) var startIfNeededCallCount = 0
private(set) var shutdownCompletelyCallCount = 0
func setAutoStartAllowed(_ allowed: Bool) {
autoStartAllowedValues.append(allowed)
}
func startIfNeeded() {
startIfNeededCallCount += 1
}
func shutdownCompletely() {
shutdownCompletelyCallCount += 1
}
}
@MainActor
private final class MockNetworkActivationRelayController: NetworkActivationRelayControlling {
private(set) var connectCallCount = 0
private(set) var disconnectCallCount = 0
func connect() {
connectCallCount += 1
}
func disconnect() {
disconnectCallCount += 1
}
}
private final class MockNetworkActivationProxyController: NetworkActivationProxyControlling {
private(set) var proxyModes: [Bool] = []
func setProxyMode(useTor: Bool) {
proxyModes.append(useTor)
}
}
@@ -0,0 +1,441 @@
import Combine
import XCTest
@testable import bitchat
@MainActor
final class NostrRelayManagerTests: XCTestCase {
func test_permissionPublisher_addsAndRemovesDefaultRelays() async {
let context = makeContext(permission: .denied, favorites: [])
XCTAssertEqual(context.manager.getRelayStatuses().count, 0)
context.permissionSubject.send(.authorized)
let defaultRelaysConnected = await waitUntil {
context.manager.getRelayStatuses().count == 5 &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(defaultRelaysConnected)
context.permissionSubject.send(.denied)
let defaultRelaysRemoved = await waitUntil {
context.manager.getRelayStatuses().isEmpty
}
XCTAssertTrue(defaultRelaysRemoved)
XCTAssertEqual(context.sessionFactory.allConnections.count, 5)
XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 })
}
func test_connect_waitsForTorReadinessBeforeCreatingSessions() async {
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
context.manager.connect()
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.torWaiter.resolve(true)
let connectedAfterTorReady = await waitUntil {
context.sessionFactory.requestedURLs.count == 5 &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connectedAfterTorReady)
}
func test_connect_doesNothingWhenActivationIsDisallowed() {
let context = makeContext(permission: .authorized, activationAllowed: false)
context.manager.connect()
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
XCTAssertFalse(context.manager.isConnected)
}
func test_ensureConnections_deduplicatesRelayURLs() async {
let relayOne = "wss://relay-one.example"
let relayTwo = "wss://relay-two.example"
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: [relayOne, relayOne, relayTwo])
let connected = await waitUntil {
Set(context.manager.getRelayStatuses().map(\.url)) == Set([relayOne, relayTwo]) &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connected)
XCTAssertEqual(context.sessionFactory.requestedURLs, [relayOne, relayTwo])
}
func test_subscribe_coalescesRapidDuplicateRequests() async {
let relayURL = "wss://subscribe.example"
let context = makeContext(permission: .denied)
let filter = makeFilter()
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
let firstSent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(firstSent)
context.clock.now = context.clock.now.addingTimeInterval(0.5)
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
}
func test_unsubscribe_allowsResubscribeWithSameID() async {
let relayURL = "wss://subscribe.example"
let context = makeContext(permission: .denied)
let filter = makeFilter()
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
let initialSubscribeSent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(initialSubscribeSent)
context.manager.unsubscribe(id: "sub")
let closeSent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 2
}
XCTAssertTrue(closeSent)
context.clock.now = context.clock.now.addingTimeInterval(0.2)
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
let resubscribed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 3
}
XCTAssertTrue(resubscribed)
}
func test_receiveEvent_deliversHandlerAndTracksReceivedCount() async throws {
let relayURL = "wss://events.example"
let context = makeContext(permission: .denied)
let filter = makeFilter()
let event = try makeSignedEvent(content: "hello")
var receivedEvent: NostrEvent?
context.manager.subscribe(filter: filter, id: "events", relayUrls: [relayURL]) { event in
receivedEvent = event
}
let subscriptionSent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscriptionSent)
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "events", event: event)
let delivered = await waitUntil {
receivedEvent?.id == event.id &&
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
}
XCTAssertTrue(delivered)
XCTAssertEqual(receivedEvent?.id, event.id)
}
func test_eoseCallback_waitsForAllTargetedRelays() async throws {
let relayOne = "wss://one.example"
let relayTwo = "wss://two.example"
let context = makeContext(permission: .denied)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "eose",
relayUrls: [relayOne, relayTwo],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
let bothConnected = await waitUntil {
context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 &&
context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1
}
XCTAssertTrue(bothConnected)
try context.sessionFactory.latestConnection(for: relayOne)?.emitEOSE(subscriptionID: "eose")
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(eoseCount, 0)
try context.sessionFactory.latestConnection(for: relayTwo)?.emitEOSE(subscriptionID: "eose")
let eoseCompleted = await waitUntil { eoseCount == 1 }
XCTAssertTrue(eoseCompleted)
}
func test_receiveFailure_schedulesReconnectWithBackoff() async {
let relayURL = "wss://retry.example"
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: [relayURL])
let firstConnected = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL) != nil
}
XCTAssertTrue(firstConnected)
let firstConnection = context.sessionFactory.latestConnection(for: relayURL)
firstConnection?.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut))
let retryScheduled = await waitUntil {
context.scheduler.scheduled.count == 1 &&
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 1
}
XCTAssertTrue(retryScheduled)
XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrRelayInitialBackoffSeconds)
let initialRequestCount = context.sessionFactory.requestedURLs.count
context.scheduler.runNext()
let retried = await waitUntil {
context.sessionFactory.requestedURLs.count == initialRequestCount + 1
}
XCTAssertTrue(retried)
}
func test_disconnect_invalidatesScheduledReconnectGeneration() async {
let relayURL = "wss://disconnect.example"
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: [relayURL])
let firstConnected = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL) != nil
}
XCTAssertTrue(firstConnected)
context.sessionFactory.latestConnection(for: relayURL)?.fail(
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
)
let retryScheduled = await waitUntil { context.scheduler.scheduled.count == 1 }
XCTAssertTrue(retryScheduled)
let requestCountBeforeDisconnect = context.sessionFactory.requestedURLs.count
context.manager.disconnect()
context.scheduler.runNext()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(context.sessionFactory.requestedURLs.count, requestCountBeforeDisconnect)
}
private func makeContext(
permission: LocationChannelManager.PermissionState,
favorites: Set<Data> = [],
activationAllowed: Bool = true,
userTorEnabled: Bool = false,
torEnforced: Bool = false,
torIsReady: Bool = true
) -> RelayManagerTestContext {
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)
let sessionFactory = MockRelaySessionFactory()
let scheduler = MockRelayScheduler()
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
let torWaiter = MockTorWaiter(isReady: torIsReady)
let manager = NostrRelayManager(
dependencies: NostrRelayManagerDependencies(
activationAllowed: { activationAllowed },
userTorEnabled: { userTorEnabled },
hasMutualFavorites: { !favoritesSubject.value.isEmpty },
hasLocationPermission: { permissionSubject.value == .authorized },
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
torEnforced: { torEnforced },
torIsReady: { torWaiter.isReady },
torIsForeground: { true },
awaitTorReady: torWaiter.await(completion:),
makeSession: { sessionFactory },
scheduleAfter: scheduler.schedule(delay:action:),
now: { clock.now }
)
)
return RelayManagerTestContext(
manager: manager,
permissionSubject: permissionSubject,
favoritesSubject: favoritesSubject,
sessionFactory: sessionFactory,
scheduler: scheduler,
clock: clock,
torWaiter: torWaiter
)
}
private func makeFilter() -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [NostrProtocol.EventKind.textNote.rawValue]
filter.limit = 10
return filter
}
private func makeSignedEvent(content: String) throws -> NostrEvent {
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .textNote,
tags: [],
content: content
)
return try event.sign(with: identity.schnorrSigningKey())
}
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping @MainActor () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return true
}
try? await Task.sleep(nanoseconds: 10_000_000)
}
return condition()
}
}
@MainActor
private struct RelayManagerTestContext {
let manager: NostrRelayManager
let permissionSubject: CurrentValueSubject<LocationChannelManager.PermissionState, Never>
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
let sessionFactory: MockRelaySessionFactory
let scheduler: MockRelayScheduler
let clock: MutableClock
let torWaiter: MockTorWaiter
}
private final class MutableClock {
var now: Date
init(now: Date) {
self.now = now
}
}
private final class MockTorWaiter {
private var completions: [(Bool) -> Void] = []
var isReady: Bool
init(isReady: Bool) {
self.isReady = isReady
}
func await(completion: @escaping (Bool) -> Void) {
completions.append(completion)
}
func resolve(_ ready: Bool) {
isReady = ready
let pending = completions
completions.removeAll()
pending.forEach { $0(ready) }
}
}
private final class MockRelayScheduler {
struct ScheduledAction {
let delay: TimeInterval
let action: () -> Void
}
private(set) var scheduled: [ScheduledAction] = []
func schedule(delay: TimeInterval, action: @escaping () -> Void) {
scheduled.append(ScheduledAction(delay: delay, action: action))
}
func runNext() {
guard !scheduled.isEmpty else { return }
let next = scheduled.removeFirst()
next.action()
}
}
private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
private(set) var requestedURLs: [String] = []
private(set) var connectionsByURL: [String: [MockRelayConnection]] = [:]
var allConnections: [MockRelayConnection] {
connectionsByURL.values.flatMap { $0 }
}
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
requestedURLs.append(url.absoluteString)
let connection = MockRelayConnection(url: url.absoluteString)
connectionsByURL[url.absoluteString, default: []].append(connection)
return connection
}
func latestConnection(for url: String) -> MockRelayConnection? {
connectionsByURL[url]?.last
}
}
private final class MockRelayConnection: NostrRelayConnectionProtocol {
private let url: String
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
private(set) var resumeCallCount = 0
private(set) var cancelCallCount = 0
private(set) var sentMessages: [URLSessionWebSocketTask.Message] = []
var sentStrings: [String] {
sentMessages.compactMap {
switch $0 {
case .string(let string): string
case .data(let data): String(data: data, encoding: .utf8)
@unknown default: nil
}
}
}
init(url: String) {
self.url = url
}
func resume() {
resumeCallCount += 1
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
cancelCallCount += 1
}
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
sentMessages.append(message)
completionHandler(nil)
}
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
receiveHandler = completionHandler
}
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
pongReceiveHandler(nil)
}
func fail(error: Error) {
let handler = receiveHandler
receiveHandler = nil
handler?(.failure(error))
}
func emitEventMessage(subscriptionID: String, event: NostrEvent) throws {
let eventData = try JSONEncoder().encode(event)
let eventJSONObject = try JSONSerialization.jsonObject(with: eventData) as! [String: Any]
let payload: [Any] = ["EVENT", subscriptionID, eventJSONObject]
try emit(jsonObject: payload)
}
func emitEOSE(subscriptionID: String) throws {
try emit(jsonObject: ["EOSE", subscriptionID])
}
private func emit(jsonObject: Any) throws {
let data = try JSONSerialization.data(withJSONObject: jsonObject)
let handler = receiveHandler
receiveHandler = nil
handler?(.success(.data(data)))
}
}
@@ -0,0 +1,122 @@
import XCTest
import UserNotifications
@testable import bitchat
final class NotificationServiceTests: XCTestCase {
func test_requestAuthorization_skipsWhenRunningTests() {
let authorizer = RecordingNotificationAuthorizer()
let service = NotificationService(
isRunningTestsProvider: { true },
authorizer: authorizer,
requestDeliverer: RecordingNotificationRequestDeliverer()
)
service.requestAuthorization()
XCTAssertEqual(authorizer.requestCallCount, 0)
}
func test_requestAuthorization_requestsAlertSoundAndBadgePermissions() {
let authorizer = RecordingNotificationAuthorizer()
let service = NotificationService(
isRunningTestsProvider: { false },
authorizer: authorizer,
requestDeliverer: RecordingNotificationRequestDeliverer()
)
service.requestAuthorization()
XCTAssertEqual(authorizer.requestCallCount, 1)
XCTAssertEqual(authorizer.lastOptions, [.alert, .sound, .badge])
}
func test_sendLocalNotification_buildsImmediateRequestWithUserInfo() {
let deliverer = RecordingNotificationRequestDeliverer()
let service = NotificationService(
isRunningTestsProvider: { false },
authorizer: RecordingNotificationAuthorizer(),
requestDeliverer: deliverer
)
service.sendLocalNotification(
title: "Hello",
body: "World",
identifier: "custom-id",
userInfo: ["peerID": "abcd"],
interruptionLevel: .timeSensitive
)
let request = deliverer.requests.singleValue
XCTAssertEqual(request?.identifier, "custom-id")
XCTAssertEqual(request?.content.title, "Hello")
XCTAssertEqual(request?.content.body, "World")
XCTAssertEqual(request?.content.userInfo["peerID"] as? String, "abcd")
XCTAssertEqual(request?.content.interruptionLevel, .timeSensitive)
XCTAssertNil(request?.trigger)
}
func test_sendPrivateMessageNotification_populatesPeerMetadata() {
let deliverer = RecordingNotificationRequestDeliverer()
let service = NotificationService(
isRunningTestsProvider: { false },
authorizer: RecordingNotificationAuthorizer(),
requestDeliverer: deliverer
)
let peerID = PeerID(str: "deadbeefdeadbeef")
service.sendPrivateMessageNotification(from: "Alice", message: "hi", peerID: peerID)
let request = deliverer.requests.singleValue
XCTAssertEqual(request?.content.title, "🔒 DM from Alice")
XCTAssertEqual(request?.content.body, "hi")
XCTAssertEqual(request?.content.userInfo["peerID"] as? String, peerID.id)
XCTAssertEqual(request?.content.userInfo["senderName"] as? String, "Alice")
}
func test_wrapperNotifications_setExpectedIdentifiersAndDeepLinks() {
let deliverer = RecordingNotificationRequestDeliverer()
let service = NotificationService(
isRunningTestsProvider: { false },
authorizer: RecordingNotificationAuthorizer(),
requestDeliverer: deliverer
)
service.sendGeohashActivityNotification(geohash: "87yv", bodyPreview: "Someone is here")
service.sendNetworkAvailableNotification(peerCount: 2)
XCTAssertEqual(deliverer.requests.count, 2)
XCTAssertEqual(deliverer.requests[0].content.userInfo["deeplink"] as? String, "bitchat://geohash/87yv")
XCTAssertTrue(deliverer.requests[0].identifier.hasPrefix("geo-activity-87yv-"))
XCTAssertEqual(deliverer.requests[1].identifier, "network-available")
XCTAssertEqual(deliverer.requests[1].content.interruptionLevel, .timeSensitive)
XCTAssertEqual(deliverer.requests[1].content.body, "2 people around")
}
}
private final class RecordingNotificationAuthorizer: NotificationAuthorizing {
private(set) var requestCallCount = 0
private(set) var lastOptions: UNAuthorizationOptions?
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
) {
requestCallCount += 1
lastOptions = options
completionHandler(true, nil)
}
}
private final class RecordingNotificationRequestDeliverer: NotificationRequestDelivering {
private(set) var requests: [UNNotificationRequest] = []
func add(_ request: UNNotificationRequest) {
requests.append(request)
}
}
private extension Array {
var singleValue: Element? {
count == 1 ? self[0] : nil
}
}
@@ -0,0 +1,138 @@
import Foundation
import XCTest
@testable import bitchat
final class SecureIdentityStateManagerTests: XCTestCase {
func test_upsertCryptographicIdentity_tracksByPeerIDPrefixAndClaimedNickname() async {
let manager = SecureIdentityStateManager(MockKeychain())
let noisePublicKey = Data(repeating: 0x11, count: 32)
let signingPublicKey = Data(repeating: 0x22, count: 32)
let fingerprint = noisePublicKey.sha256Fingerprint()
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
claimedNickname: "Alice"
)
let socialIdentityLoaded = await waitUntil {
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "Alice"
}
XCTAssertTrue(socialIdentityLoaded)
let matches = manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(publicKey: noisePublicKey))
XCTAssertEqual(matches.count, 1)
XCTAssertEqual(matches.first?.fingerprint, fingerprint)
XCTAssertEqual(matches.first?.publicKey, noisePublicKey)
XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey)
}
func test_setBlocked_clearsFavoriteState() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "ab", count: 32)
manager.setFavorite(fingerprint, isFavorite: true)
let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
XCTAssertTrue(favoriteSet)
manager.setBlocked(fingerprint, isBlocked: true)
let blockedSet = await waitUntil { manager.isBlocked(fingerprint: fingerprint) }
XCTAssertTrue(blockedSet)
XCTAssertFalse(manager.isFavorite(fingerprint: fingerprint))
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown")
}
func test_setVerified_updatesTrustLevelAndVerifiedSet() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "cd", count: 32)
manager.setFavorite(fingerprint, isFavorite: false)
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
manager.setVerified(fingerprint: fingerprint, verified: true)
let verifiedSet = await waitUntil { manager.isVerified(fingerprint: fingerprint) }
XCTAssertTrue(verifiedSet)
XCTAssertTrue(manager.getVerifiedFingerprints().contains(fingerprint))
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.trustLevel, .verified)
}
func test_forceSave_persistsFavoriteStateAcrossReinit() async {
let keychain = MockKeychain()
let manager = SecureIdentityStateManager(keychain)
let fingerprint = String(repeating: "ef", count: 32)
manager.setFavorite(fingerprint, isFavorite: true)
let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
XCTAssertTrue(favoriteSet)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertTrue(reloaded.isFavorite(fingerprint: fingerprint))
}
func test_setNostrBlocked_normalizesToLowercaseAndPersists() async {
let keychain = MockKeychain()
let manager = SecureIdentityStateManager(keychain)
let pubkey = "ABCDEF1234"
manager.setNostrBlocked(pubkey, isBlocked: true)
let nostrBlocked = await waitUntil {
manager.isNostrBlocked(pubkeyHexLowercased: pubkey.lowercased())
}
XCTAssertTrue(nostrBlocked)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertEqual(reloaded.getBlockedNostrPubkeys(), Set([pubkey.lowercased()]))
XCTAssertTrue(reloaded.isNostrBlocked(pubkeyHexLowercased: pubkey))
}
func test_corruptPersistedCache_fallsBackToEmptyState() {
let keychain = MockKeychain()
_ = keychain.saveIdentityKey(Data(repeating: 0x01, count: 32), forKey: "identityCacheEncryptionKey")
_ = keychain.saveIdentityKey(Data([0xFF, 0x00, 0xAA]), forKey: "bitchat.identityCache.v2")
let manager = SecureIdentityStateManager(keychain)
XCTAssertTrue(manager.getFavorites().isEmpty)
XCTAssertTrue(manager.getVerifiedFingerprints().isEmpty)
XCTAssertTrue(manager.getBlockedNostrPubkeys().isEmpty)
}
func test_clearAllIdentityData_removesCachedState() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "12", count: 32)
manager.setFavorite(fingerprint, isFavorite: true)
manager.setVerified(fingerprint: fingerprint, verified: true)
manager.setNostrBlocked("ABCD", isBlocked: true)
let primed = await waitUntil {
manager.isFavorite(fingerprint: fingerprint) &&
manager.isVerified(fingerprint: fingerprint)
}
XCTAssertTrue(primed)
manager.clearAllIdentityData()
let cleared = await waitUntil {
!manager.isFavorite(fingerprint: fingerprint) &&
!manager.isVerified(fingerprint: fingerprint) &&
manager.getBlockedNostrPubkeys().isEmpty
}
XCTAssertTrue(cleared)
}
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return true
}
try? await Task.sleep(nanoseconds: 10_000_000)
}
return condition()
}
}
@@ -0,0 +1,128 @@
import XCTest
@testable import bitchat
final class VerificationServiceTests: XCTestCase {
func test_buildMyQRString_roundTripsSuccessfully() throws {
let (service, noise) = makeService()
let nickname = "alice-\(UUID().uuidString)"
let npub = "npub1testvalue"
let qrString = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: npub))
let parsed = try XCTUnwrap(service.verifyScannedQR(qrString))
XCTAssertEqual(parsed.nickname, nickname)
XCTAssertEqual(parsed.npub, npub)
XCTAssertEqual(parsed.noiseKeyHex, noise.getStaticPublicKeyData().hexEncodedString())
XCTAssertEqual(parsed.signKeyHex, noise.getSigningPublicKeyData().hexEncodedString())
}
func test_buildMyQRString_returnsCachedValueForSameInputs() throws {
let (service, _) = makeService()
let nickname = "cache-\(UUID().uuidString)"
let first = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: nil))
let second = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: nil))
XCTAssertEqual(first, second)
}
func test_verifyScannedQR_rejectsExpiredPayload() throws {
let (service, noise) = makeService()
let oldTimestamp = Int64(Date().addingTimeInterval(-3600).timeIntervalSince1970)
let qrString = try makeSignedQR(
noise: noise,
nickname: "expired-\(UUID().uuidString)",
npub: nil,
ts: oldTimestamp
)
XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60))
}
func test_verifyScannedQR_rejectsTamperedSignature() throws {
let (service, noise) = makeService()
let badSignature = Data(repeating: 0xAA, count: 64)
let qrString = try makeSignedQR(
noise: noise,
nickname: "tampered-\(UUID().uuidString)",
npub: nil,
ts: Int64(Date().timeIntervalSince1970),
signatureOverride: badSignature
)
XCTAssertNil(service.verifyScannedQR(qrString))
}
func test_buildVerifyChallenge_roundTripsThroughNoisePayload() throws {
let (service, _) = makeService()
let noiseKeyHex = String(repeating: "ab", count: 32)
let nonce = Data([0x01, 0x02, 0x03, 0x04])
let encoded = service.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonce)
let payload = try XCTUnwrap(NoisePayload.decode(encoded))
let parsed = try XCTUnwrap(service.parseVerifyChallenge(payload.data))
XCTAssertEqual(payload.type, .verifyChallenge)
XCTAssertEqual(parsed.noiseKeyHex, noiseKeyHex)
XCTAssertEqual(parsed.nonceA, nonce)
}
func test_buildVerifyResponse_roundTripsAndVerifiesSignature() throws {
let (service, noise) = makeService()
let noiseKeyHex = String(repeating: "cd", count: 32)
let nonce = Data([0x10, 0x20, 0x30, 0x40, 0x50])
let encoded = try XCTUnwrap(service.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonce))
let payload = try XCTUnwrap(NoisePayload.decode(encoded))
let parsed = try XCTUnwrap(service.parseVerifyResponse(payload.data))
XCTAssertEqual(payload.type, .verifyResponse)
XCTAssertEqual(parsed.noiseKeyHex, noiseKeyHex)
XCTAssertEqual(parsed.nonceA, nonce)
XCTAssertTrue(
service.verifyResponseSignature(
noiseKeyHex: parsed.noiseKeyHex,
nonceA: parsed.nonceA,
signature: parsed.signature,
signerPublicKeyHex: noise.getSigningPublicKeyData().hexEncodedString()
)
)
XCTAssertFalse(
service.verifyResponseSignature(
noiseKeyHex: parsed.noiseKeyHex,
nonceA: Data([0xFF]),
signature: parsed.signature,
signerPublicKeyHex: noise.getSigningPublicKeyData().hexEncodedString()
)
)
}
private func makeService() -> (VerificationService, NoiseEncryptionService) {
let noise = NoiseEncryptionService(keychain: MockKeychain())
let service = VerificationService()
service.configure(with: noise)
return (service, noise)
}
private func makeSignedQR(
noise: NoiseEncryptionService,
nickname: String,
npub: String?,
ts: Int64,
signatureOverride: Data? = nil
) throws -> String {
var payload = VerificationService.VerificationQR(
v: 1,
noiseKeyHex: noise.getStaticPublicKeyData().hexEncodedString(),
signKeyHex: noise.getSigningPublicKeyData().hexEncodedString(),
npub: npub,
nickname: nickname,
ts: ts,
nonceB64: Data((0..<16).map(UInt8.init)).base64EncodedString(),
sigHex: ""
)
let signature = try XCTUnwrap(signatureOverride ?? noise.signData(payload.canonicalBytes()))
payload.sigHex = signature.hexEncodedString()
return payload.toURLString()
}
}