Files
bitchat/bitchatTests/Services/NostrRelayManagerTests.swift
T

1724 lines
70 KiB
Swift

import Combine
import XCTest
@testable import bitchat
@MainActor
final class NostrRelayManagerTests: XCTestCase {
private let expectedDefaultRelayCount = 4
func test_connect_directMode_connectsExistingDefaultRelaysWhenActivationBecomesAllowed() async {
let context = makeContext(permission: .authorized, activationAllowed: false)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.activationAllowed.value = true
context.manager.connect()
let connected = await waitUntil {
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connected)
}
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 == self.expectedDefaultRelayCount &&
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, expectedDefaultRelayCount)
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 == self.expectedDefaultRelayCount &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connectedAfterTorReady)
}
func test_connect_coalescesRepeatedCallsWhileWaitingForTor() async {
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
context.manager.connect()
context.manager.connect()
context.manager.connect()
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.torWaiter.resolve(true)
let connectedAfterTorReady = await waitUntil {
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connectedAfterTorReady)
}
func test_connect_whenTorReadinessFailsDoesNotCreateSessions() async {
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
context.manager.connect()
context.torWaiter.resolve(false)
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
XCTAssertFalse(context.manager.isConnected)
}
func test_connect_retriesTorWaitAndConnectsWhenTorBecomesReady() async {
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
context.manager.connect()
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
context.torWaiter.resolve(false)
// A failed wait re-queues the same targets and waits again instead of dropping them.
XCTAssertEqual(context.torWaiter.awaitCallCount, 2)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.torWaiter.resolve(true)
let connected = await waitUntil {
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connected)
}
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
let relayURL = "wss://tor-eose-unblock.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "tor-sub-unblock",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
for _ in 0..<TransportConfig.nostrTorReadyMaxWaitAttempts {
context.torWaiter.resolve(false)
}
// Fail-closed (no sessions), but the EOSE caller is unblocked.
XCTAssertEqual(eoseCount, 1)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
}
func test_subscribe_parkedEOSEFiresAfterFallbackTimeoutWhenTorNeverBecomesReady() async {
let relayURL = "wss://tor-eose-parked-fallback.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "tor-parked-fallback",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
// Parking the callback schedules the normal EOSE fallback, not just
// the Tor retry-exhaustion unblock (~minutes later).
XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrSubscriptionEOSEFallbackSeconds)
XCTAssertEqual(eoseCount, 0)
context.scheduler.runNext()
let unblocked = await waitUntil { eoseCount == 1 }
XCTAssertTrue(unblocked)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
// A later retry-exhaustion unblock must not fire the callback again.
for _ in 0..<TransportConfig.nostrTorReadyMaxWaitAttempts {
context.torWaiter.resolve(false)
}
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(eoseCount, 1)
}
func test_subscribe_parkedEOSEFallbackIsNoOpWhenTorRecoversFirst() async throws {
let relayURL = "wss://tor-eose-parked-recover.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "tor-parked-recover",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
// Tor recovers before the fallback fires; the callback is promoted to
// a real EOSE tracker when the subscription flushes.
context.torWaiter.resolve(true)
let subscribed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscribed)
// The parked fallback (scheduled first) is now a no-op.
context.scheduler.runNext()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(eoseCount, 0)
// The real EOSE still completes the initial load exactly once...
try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "tor-parked-recover")
let eoseCompleted = await waitUntil { eoseCount == 1 }
XCTAssertTrue(eoseCompleted)
// ...and the promoted tracker's own fallback does not double-fire.
context.scheduler.runNext()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(eoseCount, 1)
}
func test_subscribe_parkedEOSEFallbackIsNoOpAfterRetryExhaustionUnblock() async {
let relayURL = "wss://tor-eose-parked-exhausted.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "tor-parked-exhausted",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
// Retry exhaustion unblocks the parked callback first.
for _ in 0..<TransportConfig.nostrTorReadyMaxWaitAttempts {
context.torWaiter.resolve(false)
}
XCTAssertEqual(eoseCount, 1)
// The parked fallback finds nothing to do.
context.scheduler.runNext()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(eoseCount, 1)
}
func test_sendEvent_survivesFailedTorWaitAndSendsWhenTorRecovers() async throws {
let relayURL = "wss://tor-send-retry.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
let event = try makeSignedEvent(content: "queued through tor stall")
context.manager.sendEvent(event, to: [relayURL])
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.torWaiter.resolve(false)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
context.torWaiter.resolve(true)
let sent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(sent)
}
func test_sendEvent_pendingQueueDropsOldestBeyondCap() async throws {
let relayURL = "wss://tor-send-cap.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
let identity = try NostrIdentity.generate()
for i in 0...(TransportConfig.nostrPendingSendQueueCap + 4) {
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .textNote,
tags: [],
content: "cap-\(i)"
)
context.manager.sendEvent(try event.sign(with: identity.schnorrSigningKey()), to: [relayURL])
}
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, TransportConfig.nostrPendingSendQueueCap)
}
func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
let relayURL = "wss://tor-ready.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
let event = try makeSignedEvent(content: "deferred")
context.manager.sendEvent(event, to: [relayURL])
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.torWaiter.resolve(true)
let sentAfterTorReady = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&
context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1
}
XCTAssertTrue(sentAfterTorReady)
}
func test_sendEvent_queuesUntilRelayIsMarkedConnected() async throws {
let relayURL = "wss://connect-before-send.example"
let context = makeContext(permission: .denied)
let event = try makeSignedEvent(content: "wait for connected")
context.manager.sendEvent(event, to: [relayURL])
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 0)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
let sentAfterConnected = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&
context.manager.debugPendingMessageQueueCount == 0 &&
context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1
}
XCTAssertTrue(sentAfterConnected)
}
func test_sendEvent_queuesWhileBackgroundedAndFlushesWhenForegrounded() async throws {
let relayURL = "wss://queue-flush.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torIsForeground: false
)
let event = try makeSignedEvent(content: "queued")
context.manager.sendEvent(event, to: [relayURL])
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.torForeground.value = true
context.manager.ensureConnections(to: [relayURL])
let flushed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&
context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1
}
XCTAssertTrue(flushed)
}
func test_sendEvent_sendFailureDoesNotIncrementMessageCount() async throws {
let relayURL = "wss://send-failure.example"
let context = makeContext(permission: .denied)
context.sessionFactory.sendErrorByURL[relayURL] = NSError(domain: "send", code: 1)
let event = try makeSignedEvent(content: "send failure")
context.manager.sendEvent(event, to: [relayURL])
let attempted = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(attempted)
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent, 0)
}
func test_sendEvent_queueIsPrunedWhenDefaultRelaysAreRevoked() async throws {
let context = makeContext(
permission: .authorized,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torIsForeground: false
)
let event = try makeSignedEvent(content: "queued default")
context.manager.sendEvent(event)
let queued = await waitUntil {
context.manager.debugPendingMessageQueueCount == 1
}
XCTAssertTrue(queued)
context.permissionSubject.send(.denied)
let cleared = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0 &&
context.manager.relays.isEmpty
}
XCTAssertTrue(cleared)
}
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, "wss://relay-one.example:443/", "WSS://RELAY-TWO.EXAMPLE:443"])
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_ensureConnections_coalescesTargetsWhileWaitingForTor() async {
let relayOne = "wss://tor-one.example"
let relayTwo = "wss://tor-two.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
context.manager.ensureConnections(to: [relayOne])
context.manager.ensureConnections(to: [relayTwo, relayOne])
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.torWaiter.resolve(true)
let connected = await waitUntil {
Set(context.sessionFactory.requestedURLs) == Set([relayOne, relayTwo]) &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connected)
}
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_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws {
let relayURL = "wss://tor-subscribe-coalesce.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "tor-sub",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
context.manager.subscribe(
filter: makeFilter(),
id: "tor-sub",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1)
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(eoseCount, 0)
context.torWaiter.resolve(true)
let subscribed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscribed)
try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "tor-sub")
let eoseCompleted = await waitUntil { eoseCount == 1 }
XCTAssertTrue(eoseCompleted)
}
func test_subscribe_sameActiveRequestDoesNotRequeue() async {
let relayURL = "wss://active-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(2.0)
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
}
func test_subscribe_waitsForTorReadinessAndPreservesEOSECallback() async throws {
let relayURL = "wss://tor-subscribe.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "tor-eose",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.torWaiter.resolve(true)
let subscribed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscribed)
try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "tor-eose")
let eoseCompleted = await waitUntil { eoseCount == 1 }
XCTAssertTrue(eoseCompleted)
}
func test_subscribe_withoutAllowedRelays_callsEOSEImmediatelyAndDoesNotFlushLater() async {
let context = makeContext(permission: .denied)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "blocked-defaults",
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
XCTAssertEqual(eoseCount, 1)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.permissionSubject.send(.authorized)
let connected = await waitUntil {
context.sessionFactory.allConnections.count == self.expectedDefaultRelayCount &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connected)
XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.sentStrings.isEmpty })
}
func test_permissionRevocation_clearsQueuedDefaultSubscriptions() async {
let context = makeContext(
permission: .authorized,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torIsForeground: false
)
let defaultRelay = "wss://relay.damus.io"
context.manager.subscribe(filter: makeFilter(), id: "queued-default", handler: { _ in })
let queued = await waitUntil {
context.manager.debugPendingSubscriptionCount(for: defaultRelay) == 1
}
XCTAssertTrue(queued)
context.permissionSubject.send(.denied)
let cleared = await waitUntil {
context.manager.debugPendingSubscriptionCount(for: defaultRelay) == 0 &&
context.manager.relays.isEmpty
}
XCTAssertTrue(cleared)
}
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_receiveEvent_deduplicatesSameSubscriptionEventAcrossRelays() async throws {
let firstRelayURL = "wss://events-one.example"
let secondRelayURL = "wss://events-two.example"
let context = makeContext(permission: .denied)
let event = try makeSignedEvent(content: "duplicate")
var receivedIDs: [String] = []
context.manager.subscribe(
filter: makeFilter(),
id: "events",
relayUrls: [firstRelayURL, secondRelayURL]
) { event in
receivedIDs.append(event.id)
}
let subscriptionsSent = await waitUntil {
context.sessionFactory.latestConnection(for: firstRelayURL)?.sentStrings.count == 1 &&
context.sessionFactory.latestConnection(for: secondRelayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscriptionsSent)
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
let countedOnBothRelays = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
}
XCTAssertTrue(countedOnBothRelays)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1)
}
func test_receiveEvent_duplicateFanInDeliversOnceAndCountsDrops() async throws {
let relayURLs = (0..<8).map { "wss://fan-in-\($0).example" }
let context = makeContext(permission: .denied)
let event = try makeSignedEvent(content: "fan-in")
var receivedIDs: [String] = []
context.manager.subscribe(
filter: makeFilter(),
id: "presence",
relayUrls: relayURLs
) { event in
receivedIDs.append(event.id)
}
let subscriptionsSent = await waitUntil {
relayURLs.allSatisfy { relayURL in
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
}
XCTAssertTrue(subscriptionsSent)
for relayURL in relayURLs {
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(
subscriptionID: "presence",
event: event
)
}
let countedOnEveryRelay = await waitUntil {
relayURLs.allSatisfy { relayURL in
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
}
}
XCTAssertTrue(countedOnEveryRelay)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
XCTAssertEqual(
context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "presence"),
relayURLs.count - 1
)
}
func test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache() async throws {
let firstRelayURL = "wss://invalid-first-one.example"
let secondRelayURL = "wss://invalid-first-two.example"
let context = makeContext(permission: .denied)
let event = try makeSignedEvent(content: "valid-after-invalid")
let invalidEvent = invalidSignatureCopy(of: event)
var receivedIDs: [String] = []
context.manager.subscribe(
filter: makeFilter(),
id: "events",
relayUrls: [firstRelayURL, secondRelayURL]
) { event in
receivedIDs.append(event.id)
}
let subscriptionsSent = await waitUntil {
context.sessionFactory.latestConnection(for: firstRelayURL)?.sentStrings.count == 1 &&
context.sessionFactory.latestConnection(for: secondRelayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscriptionsSent)
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
let countedOnBothRelays = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
}
XCTAssertTrue(countedOnBothRelays)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 0)
}
func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
let relayURL = "wss://missing-handler.example"
let context = makeContext(permission: .denied)
let event = try makeSignedEvent(content: "unhandled")
context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL) != nil &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(connected)
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "missing", event: event)
let counted = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
}
XCTAssertTrue(counted)
}
func test_noticeAndMalformedMessages_keepReceiveLoopAliveForLaterEvents() async throws {
let relayURL = "wss://parser.example"
let context = makeContext(permission: .denied)
var receivedIDs: [String] = []
let firstEvent = try makeSignedEvent(content: "after notice")
let secondEvent = try makeSignedEvent(content: "after malformed")
context.manager.subscribe(filter: makeFilter(), id: "parser", relayUrls: [relayURL]) { event in
receivedIDs.append(event.id)
}
let subscribed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscribed)
try context.sessionFactory.latestConnection(for: relayURL)?.emitNotice(message: "ignored")
try? await Task.sleep(nanoseconds: 20_000_000)
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "parser", event: firstEvent)
let firstDelivered = await waitUntil {
receivedIDs == [firstEvent.id]
}
XCTAssertTrue(firstDelivered)
try context.sessionFactory.latestConnection(for: relayURL)?.emitRawString("not-json")
try? await Task.sleep(nanoseconds: 20_000_000)
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "parser", event: secondEvent)
let secondDelivered = await waitUntil {
receivedIDs == [firstEvent.id, secondEvent.id]
}
XCTAssertTrue(secondDelivered)
}
func test_okMessages_clearPendingGiftWrapIDs() async throws {
let relayURL = "wss://ok.example"
let context = makeContext(permission: .denied)
let successID = "gift-wrap-success"
let failureID = "gift-wrap-failure"
context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL) != nil &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(connected)
NostrRelayManager.registerPendingGiftWrap(id: successID)
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
let successCleared = await waitUntil {
!NostrRelayManager.pendingGiftWrapIDs.contains(successID)
}
XCTAssertTrue(successCleared)
NostrRelayManager.registerPendingGiftWrap(id: failureID)
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
let failureCleared = await waitUntil {
!NostrRelayManager.pendingGiftWrapIDs.contains(failureID)
}
XCTAssertTrue(failureCleared)
}
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_eoseTimeout_invokesCallbackOnceAndIgnoresLateEOSE() async throws {
let relayURL = "wss://timeout.example"
let context = makeContext(permission: .denied)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "timeout",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
let subscribed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscribed)
// The fallback is scheduled but has not fired yet.
XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrSubscriptionEOSEFallbackSeconds)
XCTAssertEqual(eoseCount, 0)
context.scheduler.runNext()
let timedOut = await waitUntil { eoseCount == 1 }
XCTAssertTrue(timedOut)
try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "timeout")
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(eoseCount, 1)
}
func test_eose_completesWhenRelayDisconnectsBeforeEOSE() async throws {
let relayOne = "wss://eose-drop-one.example"
let relayTwo = "wss://eose-drop-two.example"
let context = makeContext(permission: .denied)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "eose-drop",
relayUrls: [relayOne, relayTwo],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
let subscribed = await waitUntil {
context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 &&
context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1
}
XCTAssertTrue(subscribed)
try context.sessionFactory.latestConnection(for: relayOne)?.emitEOSE(subscriptionID: "eose-drop")
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(eoseCount, 0)
context.sessionFactory.latestConnection(for: relayTwo)?.fail(
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
)
let completed = await waitUntil { eoseCount == 1 }
XCTAssertTrue(completed)
}
func test_reconnect_replaysActiveSubscriptionsAndDeliversEvents() async throws {
let relayURL = "wss://replay.example"
let context = makeContext(permission: .denied)
var received: [NostrEvent] = []
context.manager.subscribe(
filter: makeFilter(),
id: "replay-sub",
relayUrls: [relayURL],
handler: { received.append($0) }
)
let subscribed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.contains { $0.contains("replay-sub") } == true
}
XCTAssertTrue(subscribed)
// Drop the socket; the relay forgets the subscription with it.
context.sessionFactory.latestConnection(for: relayURL)?.fail(
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost)
)
let retryScheduled = await waitUntil { !context.scheduler.scheduled.isEmpty }
XCTAssertTrue(retryScheduled)
context.scheduler.runNext()
let replayed = await waitUntil {
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
return connections.count == 2 &&
connections.last?.sentStrings.contains { $0.contains("replay-sub") } == true
}
XCTAssertTrue(replayed)
let event = try makeSignedEvent(content: "after reconnect")
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "replay-sub", event: event)
let delivered = await waitUntil { received.count == 1 }
XCTAssertTrue(delivered)
}
func test_disconnectThenConnect_restoresSubscriptions() async {
let relayURL = "wss://restore.example"
let context = makeContext(permission: .denied)
context.manager.subscribe(filter: makeFilter(), id: "restore-sub", relayUrls: [relayURL], handler: { _ in })
let subscribed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.contains { $0.contains("restore-sub") } == true
}
XCTAssertTrue(subscribed)
// Background → foreground: connections reset, subscriptions must survive.
context.manager.disconnect()
context.manager.connect()
let resubscribed = await waitUntil {
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
return connections.count == 2 &&
connections.last?.sentStrings.contains { $0.contains("restore-sub") } == true
}
XCTAssertTrue(resubscribed)
}
func test_subscriptionSendFailure_retriesOnReconnect() async {
let relayURL = "wss://flaky-send.example"
let context = makeContext(permission: .denied)
context.sessionFactory.sendErrorByURL[relayURL] = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
context.manager.subscribe(filter: makeFilter(), id: "flaky-sub", relayUrls: [relayURL], handler: { _ in })
let attempted = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.isEmpty == false
}
XCTAssertTrue(attempted)
// The REQ send failed; the subscription must survive for the next connection.
context.sessionFactory.sendErrorByURL[relayURL] = nil
context.sessionFactory.latestConnection(for: relayURL)?.fail(
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost)
)
let retryScheduled = await waitUntil { !context.scheduler.scheduled.isEmpty }
XCTAssertTrue(retryScheduled)
context.scheduler.runNext()
let resubscribed = await waitUntil {
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
return connections.count == 2 &&
connections.last?.sentStrings.contains { $0.contains("flaky-sub") } == true
}
XCTAssertTrue(resubscribed)
}
func test_staleSendCompletionFromDeadSocket_doesNotBlockReplayOnNextConnection() async {
let relayURL = "wss://stale-completion.example"
let context = makeContext(permission: .denied)
context.manager.subscribe(filter: makeFilter(), id: "stale-sub", relayUrls: [relayURL], handler: { _ in })
// The connection exists synchronously; its REQ flush lands on a later
// main-queue tick, so deferring completions here is race-free.
let connectionA = context.sessionFactory.latestConnection(for: relayURL)
XCTAssertNotNil(connectionA)
connectionA?.deferSendCompletions = true
let reqSent = await waitUntil {
connectionA?.sentStrings.contains { $0.contains("stale-sub") } == true
}
XCTAssertTrue(reqSent)
// Socket dies while the REQ's send completion is still in flight.
connectionA?.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost))
let disconnected = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == false
}
XCTAssertTrue(disconnected)
// The stale success completion must not mark the subscription active.
connectionA?.flushDeferredSendCompletions()
try? await Task.sleep(nanoseconds: 20_000_000)
context.scheduler.runNext()
let replayed = await waitUntil {
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
return connections.count == 2 &&
connections.last?.sentStrings.contains { $0.contains("stale-sub") } == true
}
XCTAssertTrue(replayed)
}
func test_permanentFailure_decaysAfterCooldownAndRetries() async {
let relayURL = "wss://cooldown.example"
let context = makeContext(permission: .denied)
context.sessionFactory.pingErrorByURL[relayURL] = NSError(
domain: NSURLErrorDomain,
code: NSURLErrorCannotFindHost,
userInfo: [NSLocalizedDescriptionKey: "DNS failure"]
)
context.manager.ensureConnections(to: [relayURL])
let failed = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == TransportConfig.nostrRelayMaxReconnectAttempts
}
XCTAssertTrue(failed)
// Within the cooldown the relay is skipped.
let countBefore = context.sessionFactory.requestedURLs.count
context.manager.ensureConnections(to: [relayURL])
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(context.sessionFactory.requestedURLs.count, countBefore)
// After the cooldown it gets another chance and recovers.
context.sessionFactory.pingErrorByURL[relayURL] = nil
context.clock.now = context.clock.now.addingTimeInterval(TransportConfig.nostrRelayFailureCooldownSeconds + 1)
context.manager.ensureConnections(to: [relayURL])
let retried = await waitUntil {
context.sessionFactory.requestedURLs.count == countBefore + 1 &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(retried)
}
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_receiveFailure_whenActivationBecomesDisallowedDoesNotScheduleReconnect() async {
let relayURL = "wss://no-retry.example"
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL) != nil &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(connected)
context.activationAllowed.value = false
context.sessionFactory.latestConnection(for: relayURL)?.fail(
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
)
let disconnected = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == false
}
XCTAssertTrue(disconnected)
XCTAssertTrue(context.scheduler.scheduled.isEmpty)
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
}
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)
}
func test_retryConnection_cancelsActiveConnectionBeforeReconnecting() async {
let relayURL = "wss://retry-now.example"
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL) != nil &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(connected)
guard let firstConnection = context.sessionFactory.latestConnection(for: relayURL) else {
XCTFail("Expected initial connection")
return
}
let initialRequestCount = context.sessionFactory.requestedURLs.count
context.manager.retryConnection(to: relayURL)
let reconnected = await waitUntil {
guard let latest = context.sessionFactory.latestConnection(for: relayURL) else { return false }
return context.sessionFactory.requestedURLs.count == initialRequestCount + 1 &&
latest !== firstConnection
}
XCTAssertTrue(reconnected)
XCTAssertEqual(firstConnection.cancelCallCount, 1)
}
func test_retryConnection_whenTorReadinessFailsDoesNotReconnect() async {
let relayURL = "wss://retry-tor.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: true)
context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL) != nil &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(connected)
guard let firstConnection = context.sessionFactory.latestConnection(for: relayURL) else {
XCTFail("Expected initial connection")
return
}
let initialRequestCount = context.sessionFactory.requestedURLs.count
context.torWaiter.isReady = false
context.manager.retryConnection(to: relayURL)
XCTAssertEqual(firstConnection.cancelCallCount, 1)
XCTAssertEqual(context.sessionFactory.requestedURLs.count, initialRequestCount)
context.torWaiter.resolve(false)
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(context.sessionFactory.requestedURLs.count, initialRequestCount)
}
func test_resetAllConnections_clearsRelayStateAndReconnects() async {
let relayURL = "wss://reset.example"
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL) != nil &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(connected)
context.sessionFactory.latestConnection(for: relayURL)?.fail(
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
)
let failed = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 1 &&
context.manager.relays.first(where: { $0.url == relayURL })?.lastError != nil
}
XCTAssertTrue(failed)
let requestCountBeforeReset = context.sessionFactory.requestedURLs.count
context.manager.resetAllConnections()
let reset = await waitUntil {
context.sessionFactory.requestedURLs.count == requestCountBeforeReset + 1 &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true &&
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 0 &&
context.manager.relays.first(where: { $0.url == relayURL })?.nextReconnectTime == nil &&
context.manager.relays.first(where: { $0.url == relayURL })?.lastError == nil
}
XCTAssertTrue(reset)
}
func test_debugFlushMessageQueue_flushesAllConnectedRelays() async throws {
let relayOne = "wss://flush-one.example"
let relayTwo = "wss://flush-two.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torIsForeground: false
)
let event = try makeSignedEvent(content: "flush-all")
context.manager.sendEvent(event, to: [relayOne, relayTwo])
let queued = await waitUntil {
context.manager.debugPendingMessageQueueCount == 1
}
XCTAssertTrue(queued)
context.torForeground.value = true
context.manager.ensureConnections(to: [relayOne, relayTwo])
context.manager.debugFlushMessageQueue()
let flushed = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0 &&
context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 &&
context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1
}
XCTAssertTrue(flushed)
}
func test_dnsPingFailure_marksRelayPermanentCallsEOSEImmediatelyAndManualRetryReconnects() async {
let relayURL = "wss://dns-failure.example"
let context = makeContext(permission: .denied)
context.sessionFactory.pingErrorByURL[relayURL] = NSError(
domain: NSURLErrorDomain,
code: NSURLErrorCannotFindHost,
userInfo: [NSLocalizedDescriptionKey: "DNS failure"]
)
context.manager.subscribe(filter: makeFilter(), id: "dns-sub", relayUrls: [relayURL], handler: { _ in })
let permanentlyFailed = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == TransportConfig.nostrRelayMaxReconnectAttempts &&
context.scheduler.scheduled.isEmpty
}
XCTAssertTrue(permanentlyFailed)
var immediateEOSE = 0
context.manager.subscribe(
filter: makeFilter(),
id: "dns-eose",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { immediateEOSE += 1 }
)
XCTAssertEqual(immediateEOSE, 1)
context.sessionFactory.pingErrorByURL[relayURL] = nil
let requestCountBeforeRetry = context.sessionFactory.requestedURLs.count
context.manager.retryConnection(to: relayURL)
let reconnected = await waitUntil {
context.sessionFactory.requestedURLs.count == requestCountBeforeRetry + 1 &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true &&
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 0
}
XCTAssertTrue(reconnected)
}
func test_pendingSubscriptions_perRelayCapEvictsOldestByInsertionOrder() async {
let relayURL = "wss://pending-cap.example"
// Tor stalled: nothing flushes, so every REQ stays pending.
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
let cap = TransportConfig.nostrPendingSubscriptionsPerRelayCap
for i in 0..<(cap + 3) {
context.manager.subscribe(filter: makeFilter(), id: "cap-sub-\(i)", relayUrls: [relayURL], handler: { _ in })
}
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), cap)
let pendingIDs = context.manager.debugPendingSubscriptionIDs(for: relayURL)
// The three oldest entries were evicted; the newest survive.
for i in 0..<3 {
XCTAssertFalse(pendingIDs.contains("cap-sub-\(i)"), "expected cap-sub-\(i) to be evicted")
}
for i in 3..<(cap + 3) {
XCTAssertTrue(pendingIDs.contains("cap-sub-\(i)"), "expected cap-sub-\(i) to be retained")
}
}
func test_pendingSubscriptions_staleEntriesSweptOnConnectAttempt() async {
let relayURL = "wss://pending-sweep.example"
// Tor stalled: the REQ stays pending and no socket ever opens.
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
context.manager.subscribe(filter: makeFilter(), id: "stale-pending-sub", relayUrls: [relayURL], handler: { _ in })
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1)
// Just under the TTL the entry survives a connect attempt.
context.clock.now = context.clock.now.addingTimeInterval(TransportConfig.nostrPendingSubscriptionTTLSeconds - 1)
context.manager.ensureConnections(to: [relayURL])
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1)
// Past the TTL the next connect attempt sweeps it.
context.clock.now = context.clock.now.addingTimeInterval(2)
context.manager.ensureConnections(to: [relayURL])
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
}
func test_resetForPanicWipe_dropsSessionRelayStateWithoutFiringCallbacks() async throws {
let relayURL = "wss://panic-reset.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
let event = try makeSignedEvent(content: "queued before panic")
var handledEvents = 0
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "panic-sub",
relayUrls: [relayURL],
handler: { _ in handledEvents += 1 },
onEOSE: { eoseCount += 1 }
)
context.manager.sendEvent(event, to: [relayURL])
XCTAssertEqual(context.manager.debugMessageHandlerCount, 1)
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 1)
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1)
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 1)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.manager.resetForPanicWipe()
XCTAssertEqual(context.manager.debugMessageHandlerCount, 0)
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 0)
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 0)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
XCTAssertEqual(handledEvents, 0)
XCTAssertEqual(eoseCount, 0)
// Stale Tor wait and fallback callbacks from the pre-wipe generation
// must not resurrect connections or settle callbacks after reset.
context.torWaiter.resolve(true)
context.scheduler.runNext()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
XCTAssertEqual(eoseCount, 0)
}
func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async {
let relayURL = "wss://jitter-bounds.example"
// Pin the jitter source to the extremes and the midpoint of [0, 1).
let jitter = JitterSequence([0.0, 1.0.nextDown, 0.25])
let context = makeContext(permission: .denied, jitterUnit: { jitter.next() })
// Persistent ping failure: every connect attempt fails and schedules
// the next reconnect with an increasing attempt count.
context.sessionFactory.pingErrorByURL[relayURL] = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
context.manager.ensureConnections(to: [relayURL])
var delays: [TimeInterval] = []
for attempt in 1...3 {
let scheduled = await waitUntil { context.scheduler.scheduled.count == 1 }
XCTAssertTrue(scheduled, "reconnect for attempt \(attempt) was not scheduled")
delays.append(context.scheduler.scheduled[0].delay)
context.scheduler.runNext()
}
// Bases: 1s, 2s, 4s. Jitter factors: 0.8, ~1.2, 0.9.
XCTAssertEqual(delays[0], 0.8 * TransportConfig.nostrRelayInitialBackoffSeconds, accuracy: 1e-9)
XCTAssertEqual(delays[1], 1.2 * TransportConfig.nostrRelayInitialBackoffSeconds * TransportConfig.nostrRelayBackoffMultiplier, accuracy: 1e-6)
XCTAssertEqual(delays[2], 0.9 * TransportConfig.nostrRelayInitialBackoffSeconds * pow(TransportConfig.nostrRelayBackoffMultiplier, 2), accuracy: 1e-9)
}
func test_reconnectBackoff_realRandomJitterStaysInBoundsAndVaries() async {
let relayURL = "wss://jitter-random.example"
let context = makeContext(permission: .denied, jitterUnit: { Double.random(in: 0..<1) })
context.sessionFactory.pingErrorByURL[relayURL] = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
context.manager.ensureConnections(to: [relayURL])
var factors: [Double] = []
for attempt in 1...5 {
let scheduled = await waitUntil { context.scheduler.scheduled.count == 1 }
XCTAssertTrue(scheduled, "reconnect for attempt \(attempt) was not scheduled")
let base = min(
TransportConfig.nostrRelayInitialBackoffSeconds * pow(TransportConfig.nostrRelayBackoffMultiplier, Double(attempt - 1)),
TransportConfig.nostrRelayMaxBackoffSeconds
)
let factor = context.scheduler.scheduled[0].delay / base
XCTAssertGreaterThanOrEqual(factor, 1.0 - TransportConfig.nostrRelayBackoffJitterRatio)
XCTAssertLessThan(factor, 1.0 + TransportConfig.nostrRelayBackoffJitterRatio)
factors.append(factor)
context.scheduler.runNext()
}
// A real RNG must not produce a constant delay across attempts
// (5 identical uniform doubles is probability ~0).
XCTAssertGreaterThan(Set(factors).count, 1)
}
private func makeContext(
permission: LocationChannelManager.PermissionState,
favorites: Set<Data> = [],
activationAllowed: Bool = true,
userTorEnabled: Bool = false,
torEnforced: Bool = false,
torIsReady: Bool = true,
torIsForeground: Bool = true,
jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter)
) -> 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 torForeground = MutableBool(value: torIsForeground)
let activationFlag = MutableBool(value: activationAllowed)
let manager = NostrRelayManager(
dependencies: NostrRelayManagerDependencies(
activationAllowed: { activationFlag.value },
userTorEnabled: { userTorEnabled },
hasMutualFavorites: { !favoritesSubject.value.isEmpty },
hasLocationPermission: { permissionSubject.value == .authorized },
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
torEnforced: { torEnforced },
torIsReady: { torWaiter.isReady },
torIsForeground: { torForeground.value },
awaitTorReady: torWaiter.await(completion:),
makeSession: { sessionFactory },
scheduleAfter: { delay, action in
scheduler.schedule(delay: delay, action: action)
},
now: { clock.now },
jitterUnit: jitterUnit
)
)
return RelayManagerTestContext(
manager: manager,
permissionSubject: permissionSubject,
favoritesSubject: favoritesSubject,
sessionFactory: sessionFactory,
scheduler: scheduler,
clock: clock,
activationAllowed: activationFlag,
torWaiter: torWaiter,
torForeground: torForeground
)
}
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 invalidSignatureCopy(of event: NostrEvent) -> NostrEvent {
var invalid = event
invalid.sig = String(repeating: "0", count: 128)
return invalid
}
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 activationAllowed: MutableBool
let torWaiter: MockTorWaiter
let torForeground: MutableBool
}
private final class MutableClock {
var now: Date
init(now: Date) {
self.now = now
}
}
/// Deterministic jitter source: returns the queued values in order, then a
/// neutral 0.5 (jitter factor 1.0) once exhausted.
private final class JitterSequence {
private var values: [Double]
init(_ values: [Double]) {
self.values = values
}
func next() -> Double {
values.isEmpty ? 0.5 : values.removeFirst()
}
}
private final class MutableBool {
var value: Bool
init(value: Bool) {
self.value = value
}
}
private final class MockTorWaiter {
private var completions: [(Bool) -> Void] = []
private(set) var awaitCallCount = 0
var isReady: Bool
init(isReady: Bool) {
self.isReady = isReady
}
func await(completion: @escaping (Bool) -> Void) {
awaitCallCount += 1
completions.append(completion)
}
func resolve(_ ready: Bool) {
isReady = ready
let pending = completions
completions.removeAll()
pending.forEach { $0(ready) }
}
}
private final class MockRelayScheduler: @unchecked Sendable {
struct ScheduledAction {
let delay: TimeInterval
let action: @Sendable () -> Void
}
private(set) var scheduled: [ScheduledAction] = []
func schedule(delay: TimeInterval, action: @escaping @Sendable () -> 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 pingErrorByURL: [String: Error?] = [:]
var sendErrorByURL: [String: Error?] = [:]
var allConnections: [MockRelayConnection] {
connectionsByURL.values.flatMap { $0 }
}
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
requestedURLs.append(url.absoluteString)
let connection = MockRelayConnection(
url: url.absoluteString,
pingError: pingErrorByURL[url.absoluteString] ?? nil,
sendError: sendErrorByURL[url.absoluteString] ?? nil
)
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 let pingError: Error?
private let sendError: Error?
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, pingError: Error? = nil, sendError: Error? = nil) {
self.url = url
self.pingError = pingError
self.sendError = sendError
}
func resume() {
resumeCallCount += 1
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
cancelCallCount += 1
}
var deferSendCompletions = false
private var deferredSendCompletions: [(Error?) -> Void] = []
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
sentMessages.append(message)
if deferSendCompletions {
deferredSendCompletions.append(completionHandler)
} else {
completionHandler(sendError)
}
}
func flushDeferredSendCompletions() {
let pending = deferredSendCompletions
deferredSendCompletions = []
pending.forEach { $0(sendError) }
}
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
receiveHandler = completionHandler
}
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
pongReceiveHandler(pingError)
}
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])
}
func emitOK(eventID: String, success: Bool, reason: String) throws {
try emit(jsonObject: ["OK", eventID, success, reason])
}
func emitNotice(message: String) throws {
try emit(jsonObject: ["NOTICE", message])
}
func emitRawString(_ string: String) throws {
let handler = receiveHandler
receiveHandler = nil
handler?(.success(.string(string)))
}
private func emit(jsonObject: Any) throws {
let data = try JSONSerialization.data(withJSONObject: jsonObject)
let handler = receiveHandler
receiveHandler = nil
handler?(.success(.data(data)))
}
}