mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
* Make location notes robust: durable relay subscriptions, failure decay, auto-recovery Location notes (and geohash chat / DMs) intermittently stopped showing events because the Nostr relay layer lost subscriptions and blacklisted relays: - Replay active subscriptions on every relay (re)connect. Relays drop REQs with the socket; previously a drop silently killed the subscription on that relay for the rest of the session. Durable subscription intent now also survives disconnect()/resetAllConnections (background -> foreground). - Keep failed REQ sends queued instead of dropping them. - Raise the EOSE fallback from a fixed 2s Timer to a 10s injected schedule (Tor needs more than 2s), and settle EOSE trackers when a relay disconnects before answering so initial load doesn't stall. - Decay "permanently failed" relay markings after a 10-minute cooldown; previously ~9 minutes of outage (or one DNS hiccup) excluded a relay until app restart, with nothing resetting it on macOS. - Make geo relay selection deterministic (distance, then host) so publishers and subscribers with the same directory agree on relays. - Post .geoRelayDirectoryDidRefresh after a directory fetch and let LocationNotesManager auto-resubscribe out of the "no relays" state instead of requiring a manual retry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Guard subscription activation on connection identity A REQ send completion from a dead socket could land after handleDisconnection cleared the relay's subscriptions and re-mark the subscription active, making the next connection skip the durable replay and leave that relay silent. Only mark a subscription active if the completing socket is still the relay's live connection. Regression test defers send completions in the mock so the stale completion deterministically interleaves between disconnect and reconnect. Addresses Codex review on #1333. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
223 lines
7.5 KiB
Swift
223 lines
7.5 KiB
Swift
import Testing
|
|
import Combine
|
|
import Foundation
|
|
@testable import bitchat
|
|
|
|
@MainActor
|
|
struct LocationNotesManagerTests {
|
|
@Test
|
|
func subscribeWithoutRelays_setsNoRelaysState() {
|
|
var subscribeCalled = false
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in [] },
|
|
subscribe: { _, _, _, _, _ in
|
|
subscribeCalled = true
|
|
},
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in try NostrIdentity.generate() },
|
|
now: { Date() }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
|
|
#expect(subscribeCalled == false)
|
|
#expect(manager.state == .noRelays)
|
|
#expect(manager.initialLoadComplete)
|
|
#expect(manager.errorMessage == String(localized: "location_notes.error.no_relays"))
|
|
}
|
|
|
|
@Test
|
|
func noRelays_resubscribesWhenDirectoryRefreshes() async throws {
|
|
var relays: [String] = []
|
|
var subscribeCount = 0
|
|
let directorySubject = PassthroughSubject<Void, Never>()
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in relays },
|
|
subscribe: { _, _, _, _, _ in
|
|
subscribeCount += 1
|
|
},
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in try NostrIdentity.generate() },
|
|
now: { Date() },
|
|
relayDirectoryUpdates: directorySubject.eraseToAnyPublisher()
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
#expect(manager.state == .noRelays)
|
|
#expect(subscribeCount == 0)
|
|
|
|
// Directory loads later (e.g. remote fetch finished after Tor came up).
|
|
relays = ["wss://relay.one"]
|
|
directorySubject.send(())
|
|
|
|
let deadline = Date().addingTimeInterval(1.0)
|
|
while manager.state == .noRelays && Date() < deadline {
|
|
try await Task.sleep(nanoseconds: 10_000_000)
|
|
}
|
|
|
|
#expect(subscribeCount == 1)
|
|
#expect(manager.state == .loading)
|
|
#expect(manager.errorMessage == nil)
|
|
}
|
|
|
|
@Test
|
|
func sendWithoutRelays_surfacesNoRelaysError() {
|
|
var sendCalled = false
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in [] },
|
|
subscribe: { _, _, _, _, _ in },
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in sendCalled = true },
|
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
|
now: { Date() }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
manager.send(content: "hello", nickname: "tester")
|
|
|
|
#expect(sendCalled == false)
|
|
#expect(manager.state == .noRelays)
|
|
#expect(manager.errorMessage == String(localized: "location_notes.error.no_relays"))
|
|
}
|
|
|
|
@Test func subscribeUsesGeoRelaysAndAppendsNotes() throws {
|
|
var relaysCaptured: [String] = []
|
|
var storedHandler: ((NostrEvent) -> Void)?
|
|
var storedEOSE: (() -> Void)?
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { filter, id, relays, handler, eose in
|
|
#expect(filter.kinds == [1])
|
|
#expect(!id.isEmpty)
|
|
relaysCaptured = relays
|
|
storedHandler = handler
|
|
storedEOSE = eose
|
|
},
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
|
now: { Date() }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
#expect(relaysCaptured == ["wss://relay.one"])
|
|
#expect(manager.state == .loading)
|
|
|
|
let identity = try NostrIdentity.generate()
|
|
let event = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .textNote,
|
|
tags: [["g", "u4pruydq"]],
|
|
content: "hi"
|
|
)
|
|
let signed = try event.sign(with: identity.schnorrSigningKey())
|
|
storedHandler?(signed)
|
|
storedEOSE?()
|
|
|
|
#expect(manager.state == .ready)
|
|
#expect(manager.notes.count == 1)
|
|
#expect(manager.notes.first?.content == "hi")
|
|
}
|
|
|
|
@Test
|
|
func setGeohash_invalidValueIsIgnored() {
|
|
var subscribeCount = 0
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { _, _, _, _, _ in
|
|
subscribeCount += 1
|
|
},
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in try NostrIdentity.generate() },
|
|
now: { Date() }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
manager.setGeohash("not-valid")
|
|
|
|
#expect(manager.geohash == "u4pruydq")
|
|
#expect(subscribeCount == 1)
|
|
}
|
|
|
|
@Test
|
|
func refreshAndCancel_manageSubscriptions() {
|
|
var subscribeIDs: [String] = []
|
|
var unsubscribedIDs: [String] = []
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { _, id, _, _, _ in
|
|
subscribeIDs.append(id)
|
|
},
|
|
unsubscribe: { id in
|
|
unsubscribedIDs.append(id)
|
|
},
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in try NostrIdentity.generate() },
|
|
now: { Date() }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
manager.refresh()
|
|
manager.cancel()
|
|
|
|
#expect(subscribeIDs.count == 2)
|
|
#expect(unsubscribedIDs.count == 2)
|
|
#expect(manager.state == .idle)
|
|
#expect(manager.errorMessage == nil)
|
|
}
|
|
|
|
@Test
|
|
func send_successCreatesLocalEchoAndClearsError() throws {
|
|
var sentEvents: [NostrEvent] = []
|
|
let identity = try NostrIdentity.generate()
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { _, _, _, _, _ in },
|
|
unsubscribe: { _ in },
|
|
sendEvent: { event, _ in
|
|
sentEvents.append(event)
|
|
},
|
|
deriveIdentity: { _ in identity },
|
|
now: { Date(timeIntervalSince1970: 123_456) }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
manager.send(content: " hello note ", nickname: "Builder")
|
|
|
|
#expect(sentEvents.count == 1)
|
|
#expect(manager.state == .ready)
|
|
#expect(manager.errorMessage == nil)
|
|
#expect(manager.notes.first?.content == "hello note")
|
|
#expect(manager.notes.first?.displayName.hasPrefix("Builder#") == true)
|
|
}
|
|
|
|
@Test
|
|
func send_failureFormatsErrorMessageAndClearErrorRemovesIt() {
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { _, _, _, _, _ in },
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
|
now: { Date() }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
manager.send(content: "hello", nickname: "Builder")
|
|
|
|
#expect(manager.errorMessage?.isEmpty == false)
|
|
|
|
manager.clearError()
|
|
|
|
#expect(manager.errorMessage == nil)
|
|
}
|
|
|
|
private enum TestError: Error {
|
|
case shouldNotDerive
|
|
}
|
|
}
|