Files
bitchat/bitchatTests/LocationNotesManagerTests.swift
304460ee83 Nearby notes: tap-to-reveal (consent-gate the location subscription) + subscription hygiene (#1422)
* Nearby notes: tap-to-reveal before any relay REQ, plus shared subscription pool

The nearby-notes counter used to open a live building-precision (precision-8)
geohash REQ to the closest geo relays whenever the mesh public timeline was
visible — a passive location side-channel with no opt-in. Now nothing
subscribes until one explicit act reveals the counter for the session: tapping
the new "check for notes left here" line on the empty mesh timeline (static,
no network), opening the notices sheet's geo tab, or a successful /drop. The
app-info setting stays the default-ON kill switch and still gates /drop.

Subscription hygiene alongside:
- LocationNotesManager.deinit now unsubscribes its live REQ (hopping to the
  main actor like the timer teardown) instead of only invalidating timers.
- New refcounted LocationNotesPool dedupes the counter's and the notices
  sheet's identical 9-cell kind-1 REQs into one shared manager per geohash;
  both callers release-and-reacquire instead of retargeting in place, and the
  sheet releases its ref on dismissal.

The reveal affordance is localized across all 29 catalog locales, and new
NearbyNotesCounterTests cover the no-REQ-before-reveal contract, the 9-cell
filter, NIP-40 expiry handling, single unsubscribe on deactivate, and pool
refcounting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Review fixes: permission-gate the hint, exclude building cell from pre-reveal sampling, explicit-act reveal only

Fixes the verified review findings on the tap-to-reveal PR:

- Permission dead-end: the "check for notes left here" hint now renders
  only when location permission is already authorized (it never prompts).
  Previously a location-denied install could tap it, flip the sticky
  revealed flag, and get nothing for the rest of the session because
  retarget() guards on authorization. Predicate lives on
  NearbyNotesCounter.offersRevealHint(permissionState:) and is reactive
  to the published permission state.

- Building-cell sampling: background geohash sampling subscribed
  geo-sample-<gh> for every regional level including the precision-8
  building cell, pre-reveal — contradicting the claim that nothing
  building-precision hits a relay before the explicit act.
  GeoChannelCoordinator now excludes the building level until
  NearbyNotesCounter.revealed (injectable publisher for tests); coarser
  levels keep the nearby-conversation hint and participant counts
  working, and bookmarks stay exempt (bookmarking is explicit).

- Implicit reveal: opening the notices sheet no longer reveals — the
  sheet auto-lands on the geo tab whenever a location channel is
  selected, so browsing a remote geohash and opening notices revealed
  the LOCAL building subscription. reveal() now fires only on the
  person actively picking the geo segment, and only when the sheet has
  a geo scope (the empty-mesh hint tap and /drop stay as before).

- Subscription hygiene: switching the sheet geo → mesh releases the
  pooled notes manager (the REQ was left streaming behind the mesh
  board); switching back re-acquires from the pool. The dismissal
  release stays balanced — liveGeoManager is nil after the tab-switch
  release.

- VoiceOver: the hint button exposes the plain localized action text
  instead of the decorated "* 📍 … *" label.

- Removed LocationNotesManager.setGeohash: zero callers, and calling it
  on a pooled instance would corrupt the pool's keying and refcounts.

New tests: hint permission gate, explicit-geo-tab reveal contract,
building-cell sampling exclusion before/after reveal, pool
release/re-acquire round trip. Full suite (1467) green; iOS simulator
build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Deflake peer-snapshot binding waits: longTimeout for the multi-hop pipeline

CI failed once on initialization_bindsPeerSnapshotsIntoAllPeers: the
snapshot -> allPeers binding crosses the mock transport's unstructured
Task, UnifiedPeerService.updatePeers, a receive(on: main), and another
Task { @MainActor } in bindPeerService — all contending with every
parallel worker. On the failing runner the whole suite took 10.1s
(usually ~4.4s locally), so the positive 5s defaultTimeout wait lost
the race. That's exactly the case TestConstants.longTimeout documents;
passing runs return as soon as the condition holds and never pay it.

Not caused by the tap-to-reveal changes: nothing on that pipeline was
touched, and 20 full parallel-suite loops each on the branch and on
origin/main reproduce zero failures locally. The two sibling waits on
the same pipeline in this file get the same timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:47:08 +02:00

395 lines
14 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 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)
}
@Test
func ingestDropsExpiredNotesAndKeepsUnexpiredOnes() throws {
var storedHandler: ((NostrEvent) -> Void)?
let now = Date(timeIntervalSince1970: 1_700_000_000)
let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, _, _, handler, _ in
storedHandler = handler
},
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { now }
)
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
let identity = try NostrIdentity.generate()
let expired = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: now.addingTimeInterval(-3600),
kind: .textNote,
tags: [["g", "u4pruydq"], ["expiration", String(Int(now.timeIntervalSince1970) - 60)]],
content: "gone"
)
storedHandler?(try expired.sign(with: identity.schnorrSigningKey()))
let live = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: now.addingTimeInterval(-3600),
kind: .textNote,
tags: [["g", "u4pruydq"], ["expiration", String(Int(now.timeIntervalSince1970) + 3600)]],
content: "still here"
)
storedHandler?(try live.sign(with: identity.schnorrSigningKey()))
#expect(manager.notes.count == 1)
#expect(manager.notes.first?.content == "still here")
#expect(manager.notes.first?.expiresAt == Date(timeIntervalSince1970: TimeInterval(Int(now.timeIntervalSince1970) + 3600)))
}
@Test
func postDrop_sendsExpiringNoteToGeoRelays() throws {
var sentEvents: [NostrEvent] = []
let now = Date(timeIntervalSince1970: 1_700_000_000)
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: { now }
)
let posted = LocationNotesManager.postDrop(
content: " the coffee here is great ",
nickname: "scout",
geohash: "u4pruydq",
dependencies: deps
)
#expect(posted)
#expect(sentEvents.count == 1)
let event = try #require(sentEvents.first)
#expect(event.kind == NostrProtocol.EventKind.textNote.rawValue)
#expect(event.content == "the coffee here is great")
#expect(event.tags.contains(["g", "u4pruydq"]))
let expiration = event.tags.first { $0.first == "expiration" }?.last
let expected = Int(now.addingTimeInterval(TransportConfig.locationDropExpirySeconds).timeIntervalSince1970)
#expect(expiration == String(expected))
}
@Test
func postDrop_failsWithoutRelays() {
let deps = LocationNotesDependencies(
relayLookup: { _, _ in [] },
subscribe: { _, _, _, _, _ in },
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { Date() }
)
#expect(!LocationNotesManager.postDrop(content: "hi", nickname: "x", geohash: "u4pruydq", dependencies: deps))
}
@Test
func pruneExpiredNotes_dropsNotesWhoseExpiryPassed() throws {
var storedHandler: ((NostrEvent) -> Void)?
var currentNow = Date(timeIntervalSince1970: 1_700_000_000)
let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, _, _, handler, _ in
storedHandler = handler
},
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { currentNow }
)
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
let identity = try NostrIdentity.generate()
let note = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: currentNow,
kind: .textNote,
tags: [["g", "u4pruydq"], ["expiration", String(Int(currentNow.timeIntervalSince1970) + 60)]],
content: "short lived"
)
storedHandler?(try note.sign(with: identity.schnorrSigningKey()))
#expect(manager.notes.count == 1)
currentNow = currentNow.addingTimeInterval(120)
manager.pruneExpiredNotes()
#expect(manager.notes.isEmpty)
}
@Test
func eoseWithoutConnectedRelays_showsConnectingInsteadOfEmpty() {
var storedEOSE: (() -> Void)?
var deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, _, _, _, eose in storedEOSE = eose },
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { Date() }
)
deps.anyRelayConnected = { _ in false }
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
storedEOSE?()
#expect(manager.state == .connecting)
#expect(manager.initialLoadComplete)
}
@Test
func eoseWithConnectedRelayAndNoNotes_isReadyEmpty() {
var storedEOSE: (() -> Void)?
var deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, _, _, _, eose in storedEOSE = eose },
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { Date() }
)
deps.anyRelayConnected = { _ in true }
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
storedEOSE?()
#expect(manager.state == .ready)
}
@Test
func connectingState_retriesOnceARelayComesUp() {
var storedEOSE: (() -> Void)?
var subscribeCount = 0
var relayUp = false
var deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, _, _, _, eose in
subscribeCount += 1
storedEOSE = eose
},
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { Date() }
)
deps.anyRelayConnected = { _ in relayUp }
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
#expect(subscribeCount == 1)
storedEOSE?()
#expect(manager.state == .connecting)
// Relay still down: no retry.
manager.retryIfRelaysAvailable(relays: ["wss://relay.one"])
#expect(subscribeCount == 1)
// Relay up: re-subscribes for a fresh initial fetch.
relayUp = true
manager.retryIfRelaysAvailable(relays: ["wss://relay.one"])
#expect(subscribeCount == 2)
#expect(manager.state == .loading)
}
private enum TestError: Error {
case shouldNotDerive
}
}