Extract and use relay hints from bech32 entities (nevent, nprofile, naddr) and event tag references (e, q tags) to fetch events from hinted relays not in the user's relay pool. Changes: - Parse relay hints from bech32 TLV data in URLHandler - Pass relay hints through SearchType and NoteReference enums - Add ensureConnected() to RelayPool for ephemeral relay connections - Implement ephemeral relay lease management with race condition protection - Add repostTarget() helper to extract relay hints from repost e tags - Add QuoteRef struct to preserve relay hints from q tags (NIP-10/NIP-18) - Support relay hints in replies with author pubkey in e-tags (NIP-10) - Implement fallback broadcast when hinted relays don't respond - Add comprehensive test coverage for relay hint functionality - Add DEBUG logging for relay hint tracing during development Implementation details: - Connect to hinted relays as ephemeral, returning early when first connects - Use total deadline to prevent timeout accumulation across hint attempts - Decrement lease count before suspension points to ensure atomicity - Fall back to broadcast if hints don't resolve or respond Closes: https://github.com/damus-io/damus/issues/1147 Changelog-Added: Added relay hint support for nevent, nprofile, naddr links and event tag references (reposts, quotes, replies) Signed-off-by: alltheseas Signed-off-by: Daniel D'Aquino <daniel@daquino.me> Co-authored-by: alltheseas <alltheseas@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Daniel D'Aquino <daniel@daquino.me
550 lines
24 KiB
Swift
550 lines
24 KiB
Swift
//
|
||
// RelayHintsTests.swift
|
||
// damus
|
||
//
|
||
// Created by Daniel D’Aquino on 2026-02-02.
|
||
//
|
||
|
||
import XCTest
|
||
import NostrSDK
|
||
@testable import damus
|
||
|
||
/// Tests for relay hints functionality, ensuring relay hints are correctly extracted and used
|
||
/// for ephemeral relay connections per NIP-01 and NIP-10.
|
||
///
|
||
/// These tests verify that:
|
||
/// - Relay hints are correctly extracted from tags
|
||
/// - Ephemeral relays can be added and managed by RelayPool
|
||
/// - Relay hint lease management prevents premature cleanup
|
||
final class RelayHintsTests: XCTestCase {
|
||
|
||
// MARK: - Helper Functions
|
||
|
||
/// Creates and runs a local relay on a random available port.
|
||
/// - Returns: The running LocalRelay instance
|
||
private func setupRelay() async throws -> LocalRelay {
|
||
let builder = RelayBuilder()
|
||
let relay = LocalRelay(builder: builder)
|
||
try await relay.run()
|
||
print("Relay url: \(await relay.url())")
|
||
return relay
|
||
}
|
||
|
||
// MARK: - Test Cases
|
||
|
||
/// Test that TagSequence correctly extracts relay hints from e-tags per NIP-10.
|
||
/// This verifies the basic relay hint extraction functionality.
|
||
func testTagSequenceExtractsRelayHints() {
|
||
// Given: An e-tag with a relay hint at position 2
|
||
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
|
||
let relayUrl = "wss://relay.example.com"
|
||
|
||
let tags = [
|
||
["e", eventIdHex, relayUrl, "reply"]
|
||
]
|
||
|
||
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
|
||
|
||
// When: Accessing the first tag
|
||
let firstTag = note.tags[0]
|
||
|
||
// Then: Relay hint should be extracted correctly
|
||
XCTAssertEqual(firstTag.relayHint?.absoluteString, relayUrl)
|
||
XCTAssertEqual(firstTag.relayHints.count, 1)
|
||
XCTAssertEqual(firstTag.relayHints.first?.absoluteString, relayUrl)
|
||
}
|
||
|
||
/// Test that TagSequence handles tags without relay hints gracefully.
|
||
func testTagSequenceHandlesEmptyRelayHint() {
|
||
// Given: An e-tag with an empty relay hint
|
||
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
|
||
|
||
let tags = [
|
||
["e", eventIdHex, "", "reply"]
|
||
]
|
||
|
||
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
|
||
|
||
// When: Accessing the first tag
|
||
let firstTag = note.tags[0]
|
||
|
||
// Then: Relay hint should be nil for empty string
|
||
XCTAssertNil(firstTag.relayHint)
|
||
XCTAssertEqual(firstTag.relayHints.count, 0)
|
||
}
|
||
|
||
/// Test that TagSequence handles tags with fewer than 3 elements (no relay hint position).
|
||
func testTagSequenceHandlesShortTags() {
|
||
// Given: An e-tag with only 2 elements (no relay hint position)
|
||
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
|
||
|
||
let tags = [
|
||
["e", eventIdHex]
|
||
]
|
||
|
||
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
|
||
|
||
// When: Accessing the first tag
|
||
let firstTag = note.tags[0]
|
||
|
||
// Then: Relay hint should be nil
|
||
XCTAssertNil(firstTag.relayHint)
|
||
XCTAssertEqual(firstTag.relayHints.count, 0)
|
||
}
|
||
|
||
/// Test that RelayPool can add ephemeral relays.
|
||
/// This verifies the basic ephemeral relay management functionality.
|
||
func testRelayPoolAddsEphemeralRelay() async throws {
|
||
// Given: A relay pool and a relay descriptor marked as ephemeral
|
||
let ndb = await test_damus_state.ndb
|
||
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
|
||
|
||
let testRelay = try await setupRelay()
|
||
let testRelayUrl = RelayURL(await testRelay.url().description)!
|
||
|
||
let descriptor = RelayPool.RelayDescriptor(url: testRelayUrl, info: .readWrite, variant: .ephemeral)
|
||
|
||
// When: Adding the ephemeral relay
|
||
try await pool.add_relay(descriptor)
|
||
|
||
// Then: The relay should be in the pool and marked as ephemeral
|
||
let descriptors = await pool.all_descriptors
|
||
let ephemeralRelays = descriptors.filter { $0.ephemeral }
|
||
|
||
XCTAssertEqual(ephemeralRelays.count, 1, "Should have exactly one ephemeral relay")
|
||
XCTAssertEqual(ephemeralRelays.first?.url, testRelayUrl)
|
||
XCTAssertTrue(ephemeralRelays.first?.ephemeral ?? false)
|
||
|
||
// Cleanup
|
||
await pool.close()
|
||
}
|
||
|
||
/// Test that ephemeral relay lease management works correctly.
|
||
/// This ensures ephemeral relays track leases and can be released.
|
||
func testEphemeralRelayLeaseManagement() async throws {
|
||
// Given: A relay pool with an ephemeral relay
|
||
let ndb = await test_damus_state.ndb
|
||
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
|
||
|
||
let testRelay = try await setupRelay()
|
||
let testRelayUrl = RelayURL(await testRelay.url().description)!
|
||
|
||
let descriptor = RelayPool.RelayDescriptor(url: testRelayUrl, info: .readWrite, variant: .ephemeral)
|
||
try await pool.add_relay(descriptor)
|
||
|
||
// When: Acquiring a lease on the ephemeral relay
|
||
await pool.acquireEphemeralRelays([testRelayUrl])
|
||
|
||
// Then: The relay should still be in the pool
|
||
var descriptors = await pool.all_descriptors
|
||
var ephemeralRelays = descriptors.filter { $0.ephemeral }
|
||
XCTAssertEqual(ephemeralRelays.count, 1, "Should have ephemeral relay after acquiring lease")
|
||
|
||
// When: Releasing the lease
|
||
await pool.releaseEphemeralRelays([testRelayUrl])
|
||
|
||
// Give some time for cleanup
|
||
try await Task.sleep(for: .seconds(1))
|
||
|
||
// Then: The relay should be removed after releasing the lease
|
||
descriptors = await pool.all_descriptors
|
||
ephemeralRelays = descriptors.filter { $0.ephemeral && $0.url == testRelayUrl }
|
||
|
||
XCTAssertEqual(ephemeralRelays.count, 0, "Ephemeral relay should be removed after releasing lease")
|
||
|
||
// Cleanup
|
||
await pool.close()
|
||
}
|
||
|
||
/// Test that multiple leases prevent premature cleanup of ephemeral relays.
|
||
/// This ensures the reference counting mechanism works correctly.
|
||
func testMultipleLeasesPreventsCleanup() async throws {
|
||
// Given: A relay pool with an ephemeral relay
|
||
let ndb = await test_damus_state.ndb
|
||
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
|
||
|
||
let testRelay = try await setupRelay()
|
||
let testRelayUrl = RelayURL(await testRelay.url().description)!
|
||
|
||
let descriptor = RelayPool.RelayDescriptor(url: testRelayUrl, info: .readWrite, variant: .ephemeral)
|
||
try await pool.add_relay(descriptor)
|
||
|
||
// When: Acquiring two leases on the same ephemeral relay
|
||
await pool.acquireEphemeralRelays([testRelayUrl])
|
||
await pool.acquireEphemeralRelays([testRelayUrl])
|
||
|
||
// Then: Releasing one lease should not remove the relay
|
||
await pool.releaseEphemeralRelays([testRelayUrl])
|
||
try await Task.sleep(for: .milliseconds(500))
|
||
|
||
var descriptors = await pool.all_descriptors
|
||
var ephemeralRelays = descriptors.filter { $0.ephemeral && $0.url == testRelayUrl }
|
||
XCTAssertEqual(ephemeralRelays.count, 1, "Should still have ephemeral relay after releasing one of two leases")
|
||
|
||
// When: Releasing the second lease
|
||
await pool.releaseEphemeralRelays([testRelayUrl])
|
||
try await Task.sleep(for: .seconds(1))
|
||
|
||
// Then: The relay should be removed after all leases are released
|
||
descriptors = await pool.all_descriptors
|
||
ephemeralRelays = descriptors.filter { $0.ephemeral && $0.url == testRelayUrl }
|
||
|
||
XCTAssertEqual(ephemeralRelays.count, 0, "Ephemeral relay should be removed after releasing all leases")
|
||
|
||
// Cleanup
|
||
await pool.close()
|
||
}
|
||
|
||
/// Test that ensureConnected adds missing relays as ephemeral.
|
||
/// This verifies the automatic ephemeral relay addition when using relay hints.
|
||
func testEnsureConnectedAddsEphemeralRelays() async throws {
|
||
// Given: A relay pool without any relays
|
||
let ndb = await test_damus_state.ndb
|
||
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
|
||
|
||
let testRelay = try await setupRelay()
|
||
let testRelayUrl = RelayURL(await testRelay.url().description)!
|
||
|
||
// Initially no relays
|
||
var descriptors = await pool.all_descriptors
|
||
XCTAssertEqual(descriptors.count, 0, "Should have no relays initially")
|
||
|
||
// When: Ensuring connection to a relay not in the pool
|
||
let connectedRelays = await pool.ensureConnected(to: [testRelayUrl], timeout: .seconds(3))
|
||
|
||
// Then: The relay should be added as ephemeral
|
||
descriptors = await pool.all_descriptors
|
||
let ephemeralRelays = descriptors.filter { $0.ephemeral }
|
||
|
||
XCTAssertGreaterThan(descriptors.count, 0, "Should have added the relay")
|
||
XCTAssertEqual(ephemeralRelays.count, 1, "Should have one ephemeral relay")
|
||
XCTAssertEqual(ephemeralRelays.first?.url, testRelayUrl)
|
||
|
||
print("Connected relays: \(connectedRelays.map { $0.absoluteString })")
|
||
|
||
// Cleanup
|
||
await pool.close()
|
||
}
|
||
|
||
/// Test that relay hints enable fetching events from relays not in the user's pool.
|
||
/// This is an end-to-end integration test that verifies:
|
||
/// - A note exists on relayA (with the note)
|
||
/// - User is connected to relayB (empty, no notes)
|
||
/// - Using a relay hint to relayA allows fetching the note successfully
|
||
func testRelayHintFetchesEventFromCorrectRelay() async throws {
|
||
// Given: Two relays - one with a note, one empty
|
||
let relayWithNote = try await setupRelay()
|
||
let emptyRelay = try await setupRelay()
|
||
|
||
let relayWithNoteUrl = RelayURL(await relayWithNote.url().description)!
|
||
let emptyRelayUrl = RelayURL(await emptyRelay.url().description)!
|
||
|
||
// Create a test note
|
||
let testNote = NostrEvent(content: "Test note on specific relay", keypair: test_keypair)!
|
||
|
||
// Send the note to relayWithNote only
|
||
let connectionToRelayWithNote = await connectToRelay(url: relayWithNoteUrl, label: "RelayWithNote")
|
||
sendEvents([testNote], to: connectionToRelayWithNote)
|
||
|
||
// Wait for the event to be received by the relay
|
||
try await Task.sleep(for: .milliseconds(500))
|
||
|
||
// When: Network manager is connected ONLY to the empty relay (not relayWithNote)
|
||
let ndb = await test_damus_state.ndb
|
||
let networkManager = try await setupNetworkManager(with: [emptyRelayUrl], ndb: ndb)
|
||
|
||
// Verify the note is NOT in local NDB yet
|
||
let localNote = try? ndb.lookup_note_and_copy(testNote.id)
|
||
XCTAssertNil(localNote, "Note should not be in local NDB yet")
|
||
|
||
// Try to fetch WITHOUT relay hint (should fail since note is not on emptyRelay)
|
||
let lenderWithoutHint = try? await networkManager.reader.lookup(noteId: testNote.id, to: nil, timeout: .seconds(2))
|
||
XCTAssertNil(lenderWithoutHint, "Should not find note without relay hint (note is not on emptyRelay)")
|
||
|
||
// Then: Fetch WITH relay hint to relayWithNote (should succeed)
|
||
let lenderWithHint = try? await networkManager.reader.lookup(noteId: testNote.id, to: [relayWithNoteUrl], timeout: .seconds(5))
|
||
|
||
XCTAssertNotNil(lenderWithHint, "Should find note using relay hint")
|
||
|
||
// Verify the found note matches the original
|
||
var foundNote: NostrEvent?
|
||
lenderWithHint?.justUseACopy({ foundNote = $0 })
|
||
|
||
XCTAssertNotNil(foundNote, "Should be able to extract note from lender")
|
||
XCTAssertEqual(foundNote?.id, testNote.id, "Found note should match original")
|
||
XCTAssertEqual(foundNote?.content, testNote.content, "Note content should match")
|
||
|
||
// Cleanup
|
||
await networkManager.close()
|
||
}
|
||
|
||
/// Test that relay hints fall back to broadcasting when hinted relays don't respond.
|
||
/// This verifies the critical fallback mechanism that ensures notes can still be fetched
|
||
/// even when relay hints point to slow or unavailable relays.
|
||
func testRelayHintFallsBackToBroadcastWhenHintsDontRespond() async throws {
|
||
// Given: User has a relay with the note, but relay hint points to a relay WITHOUT the note
|
||
let userRelay = try await setupRelay()
|
||
let slowHintRelay = try await setupRelay()
|
||
|
||
let userRelayUrl = RelayURL(await userRelay.url().description)!
|
||
let slowHintRelayUrl = RelayURL(await slowHintRelay.url().description)!
|
||
|
||
// Create a test note
|
||
let testNote = NostrEvent(content: "Note for fallback test", keypair: test_keypair)!
|
||
|
||
// Send the note ONLY to user's relay (not to the hinted relay)
|
||
let userConnection = await connectToRelay(url: userRelayUrl, label: "UserRelay")
|
||
sendEvents([testNote], to: userConnection)
|
||
|
||
// Wait for the event to be received by the relay
|
||
try await Task.sleep(for: .milliseconds(500))
|
||
|
||
// When: Network manager is connected to user's relay
|
||
let ndb = await test_damus_state.ndb
|
||
let networkManager = try await setupNetworkManager(with: [userRelayUrl], ndb: ndb)
|
||
|
||
// Try to fetch WITH relay hint to slowHintRelay (which doesn't have the note)
|
||
// This should:
|
||
// 1. Try slowHintRelay first (will timeout/fail)
|
||
// 2. Fall back to broadcasting to userRelay
|
||
// 3. Successfully find the note
|
||
let lender = try? await networkManager.reader.lookup(noteId: testNote.id, to: [slowHintRelayUrl], timeout: .seconds(5))
|
||
|
||
// Then: Note should be found via fallback broadcast to user's relay
|
||
XCTAssertNotNil(lender, "Should find note via fallback broadcast despite bad relay hint")
|
||
|
||
var foundNote: NostrEvent?
|
||
lender?.justUseACopy({ foundNote = $0 })
|
||
|
||
XCTAssertNotNil(foundNote, "Should be able to extract note from lender")
|
||
XCTAssertEqual(foundNote?.id, testNote.id, "Found note should match original")
|
||
XCTAssertEqual(foundNote?.content, testNote.content, "Note content should match")
|
||
|
||
// Cleanup
|
||
await networkManager.close()
|
||
}
|
||
|
||
/// Test that relay hints from NIP-19 nevent entities are correctly used for lookups.
|
||
/// This verifies that nevent-style relay hints (common in nostr: URLs) work correctly.
|
||
func testRelayHintsFromNEventEntity() async throws {
|
||
// Given: A relay with a note, and nevent with relay hints
|
||
let hintedRelay = try await setupRelay()
|
||
let emptyRelay = try await setupRelay()
|
||
|
||
let hintedRelayUrl = RelayURL(await hintedRelay.url().description)!
|
||
let emptyRelayUrl = RelayURL(await emptyRelay.url().description)!
|
||
|
||
// Create a test note and send it to the hinted relay
|
||
let testNote = NostrEvent(content: "Note for nevent test", keypair: test_keypair)!
|
||
|
||
let hintedConnection = await connectToRelay(url: hintedRelayUrl, label: "HintedRelay")
|
||
sendEvents([testNote], to: hintedConnection)
|
||
|
||
try await Task.sleep(for: .milliseconds(500))
|
||
|
||
// When: Network manager is connected ONLY to empty relay
|
||
let ndb = await test_damus_state.ndb
|
||
let networkManager = try await setupNetworkManager(with: [emptyRelayUrl], ndb: ndb)
|
||
|
||
// Create an NEvent with relay hints (simulating NIP-19 parsing)
|
||
let nevent = NEvent(noteid: testNote.id, relays: [hintedRelayUrl])
|
||
|
||
// Verify nevent has relay hints
|
||
XCTAssertEqual(nevent.relays.count, 1, "NEvent should have one relay hint")
|
||
XCTAssertEqual(nevent.relays.first, hintedRelayUrl, "NEvent relay hint should match")
|
||
|
||
// Then: Use findEvent with nevent's relay hints (as it would be used in real code)
|
||
let targetRelays = nevent.relays.isEmpty ? nil : nevent.relays
|
||
let result = await networkManager.reader.findEvent(query: .event(evid: nevent.noteid, find_from: targetRelays))
|
||
|
||
// Verify we got the event back
|
||
guard case .event(let foundNote) = result else {
|
||
XCTFail("Should find note using nevent relay hints via findEvent")
|
||
return
|
||
}
|
||
|
||
XCTAssertEqual(foundNote.id, testNote.id, "Found note should match nevent note ID")
|
||
XCTAssertEqual(foundNote.content, testNote.content, "Found note content should match")
|
||
|
||
// Cleanup
|
||
await networkManager.close()
|
||
}
|
||
|
||
/// Test that relay hints work correctly when some events are cached in NDB.
|
||
/// This verifies that cached events are returned from NDB and relay hints are only
|
||
/// used for non-cached events, avoiding unnecessary network calls.
|
||
func testRelayHintsWithNDBCachedEvents() async throws {
|
||
// Given: Some notes cached in NDB, one note on a relay
|
||
let relay = try await setupRelay()
|
||
let relayUrl = RelayURL(await relay.url().description)!
|
||
|
||
// Create three notes
|
||
let cachedNoteA = NostrEvent(content: "Cached note A", keypair: test_keypair)!
|
||
let cachedNoteB = NostrEvent(content: "Cached note B", keypair: test_keypair)!
|
||
let uncachedNoteC = NostrEvent(content: "Uncached note C", keypair: test_keypair)!
|
||
|
||
// Store A and B in NDB (cached)
|
||
let ndb = await test_damus_state.ndb
|
||
storeEventsInNdb([cachedNoteA, cachedNoteB], ndb: ndb)
|
||
|
||
// Send only C to the relay
|
||
let connection = await connectToRelay(url: relayUrl, label: "Relay")
|
||
sendEvents([uncachedNoteC], to: connection)
|
||
|
||
try await Task.sleep(for: .milliseconds(500))
|
||
|
||
// When: Network manager is set up
|
||
let networkManager = try await setupNetworkManager(with: [relayUrl], ndb: ndb)
|
||
|
||
// Then: Fetch all three notes with relay hints
|
||
|
||
// Fetch cached note A (should come from NDB, not network)
|
||
let lenderA = try? await networkManager.reader.lookup(noteId: cachedNoteA.id, to: [relayUrl], timeout: .seconds(3))
|
||
XCTAssertNotNil(lenderA, "Should find cached note A")
|
||
|
||
var foundA: NostrEvent?
|
||
lenderA?.justUseACopy({ foundA = $0 })
|
||
XCTAssertEqual(foundA?.id, cachedNoteA.id, "Cached note A should match")
|
||
XCTAssertEqual(foundA?.content, "Cached note A", "Cached note A content should match")
|
||
|
||
// Fetch cached note B (should come from NDB, not network)
|
||
let lenderB = try? await networkManager.reader.lookup(noteId: cachedNoteB.id, to: [relayUrl], timeout: .seconds(3))
|
||
XCTAssertNotNil(lenderB, "Should find cached note B")
|
||
|
||
var foundB: NostrEvent?
|
||
lenderB?.justUseACopy({ foundB = $0 })
|
||
XCTAssertEqual(foundB?.id, cachedNoteB.id, "Cached note B should match")
|
||
XCTAssertEqual(foundB?.content, "Cached note B", "Cached note B content should match")
|
||
|
||
// Fetch uncached note C (should use relay hints to fetch from network)
|
||
let lenderC = try? await networkManager.reader.lookup(noteId: uncachedNoteC.id, to: [relayUrl], timeout: .seconds(3))
|
||
XCTAssertNotNil(lenderC, "Should find uncached note C via relay hints")
|
||
|
||
var foundC: NostrEvent?
|
||
lenderC?.justUseACopy({ foundC = $0 })
|
||
XCTAssertEqual(foundC?.id, uncachedNoteC.id, "Uncached note C should match")
|
||
XCTAssertEqual(foundC?.content, "Uncached note C", "Uncached note C content should match")
|
||
|
||
// Verify all notes were found correctly
|
||
XCTAssertNotNil(foundA, "Note A should be found from cache")
|
||
XCTAssertNotNil(foundB, "Note B should be found from cache")
|
||
XCTAssertNotNil(foundC, "Note C should be found from network")
|
||
|
||
// Cleanup
|
||
await networkManager.close()
|
||
}
|
||
|
||
// MARK: - Helper Functions for Integration Test
|
||
|
||
/// Connects to a relay and waits for the connection to be established.
|
||
/// - Parameters:
|
||
/// - url: The relay URL to connect to
|
||
/// - label: Optional label for logging
|
||
/// - Returns: The connected RelayConnection instance
|
||
private func connectToRelay(url: RelayURL, label: String = "") async -> RelayConnection {
|
||
var connectionContinuation: CheckedContinuation<Void, Never>?
|
||
|
||
let relayConnection = RelayConnection(url: url, handleEvent: { _ in }, processUnverifiedWSEvent: { wsEvent in
|
||
let prefix = label.isEmpty ? "" : "(\(label)) "
|
||
switch wsEvent {
|
||
case .connected:
|
||
connectionContinuation?.resume()
|
||
case .message(let message):
|
||
print("RELAY_HINTS_TEST \(prefix): Received: \(message)")
|
||
case .disconnected(let closeCode, let string):
|
||
print("RELAY_HINTS_TEST \(prefix): Disconnected: \(closeCode); \(String(describing: string))")
|
||
case .error(let error):
|
||
print("RELAY_HINTS_TEST \(prefix): Received error: \(error)")
|
||
}
|
||
})
|
||
relayConnection.connect()
|
||
|
||
// Wait for connection to be established
|
||
await withCheckedContinuation { continuation in
|
||
connectionContinuation = continuation
|
||
}
|
||
|
||
return relayConnection
|
||
}
|
||
|
||
/// Sends events to a relay connection.
|
||
/// - Parameters:
|
||
/// - events: Array of NostrEvent to send
|
||
/// - connection: The RelayConnection to send events through
|
||
private func sendEvents(_ events: [NostrEvent], to connection: RelayConnection) {
|
||
for event in events {
|
||
connection.send(.typical(.event(event)))
|
||
}
|
||
}
|
||
|
||
/// Stores events in NostrDB for testing purposes.
|
||
/// - Parameters:
|
||
/// - events: Array of NostrEvent to store in NDB
|
||
/// - ndb: The Ndb instance to store events in
|
||
private func storeEventsInNdb(_ events: [NostrEvent], ndb: Ndb) {
|
||
for event in events {
|
||
do {
|
||
try ndb.add(event: event)
|
||
} catch {
|
||
XCTFail("Failed to store event in NDB: \(error)")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Sets up a NostrNetworkManager with the specified relay URLs.
|
||
/// - Parameters:
|
||
/// - urls: Array of RelayURL to add to the manager
|
||
/// - ndb: The Ndb instance to use
|
||
/// - Returns: Configured and connected NostrNetworkManager
|
||
private func setupNetworkManager(with urls: [RelayURL], ndb: Ndb) async throws -> NostrNetworkManager {
|
||
let delegate = TestNetworkDelegate(ndb: ndb, keypair: test_keypair, bootstrapRelays: urls)
|
||
let networkManager = NostrNetworkManager(delegate: delegate, addNdbToRelayPool: true)
|
||
|
||
// Manually add relays to the pool
|
||
for url in urls {
|
||
do {
|
||
try await networkManager.userRelayList.insert(relay: .init(url: url, rwConfiguration: .readWrite), force: true)
|
||
}
|
||
catch {
|
||
switch error {
|
||
case .relayAlreadyExists: continue
|
||
default: throw error
|
||
}
|
||
}
|
||
}
|
||
|
||
// Only connect and wait if we have relays to connect to
|
||
if !urls.isEmpty {
|
||
await networkManager.userRelayList.connect()
|
||
// Wait for relay pool to be ready
|
||
try await Task.sleep(for: .seconds(2))
|
||
}
|
||
|
||
return networkManager
|
||
}
|
||
}
|
||
|
||
// MARK: - Test Doubles
|
||
|
||
/// Test delegate for NostrNetworkManager that provides minimal configuration for testing
|
||
private final class TestNetworkDelegate: NostrNetworkManager.Delegate {
|
||
var ndb: Ndb
|
||
var keypair: Keypair
|
||
var latestRelayListEventIdHex: String?
|
||
var latestContactListEvent: NostrEvent?
|
||
var bootstrapRelays: [RelayURL]
|
||
var developerMode: Bool = false
|
||
var experimentalLocalRelayModelSupport: Bool = false
|
||
var relayModelCache: RelayModelCache
|
||
var relayFilters: RelayFilters
|
||
var nwcWallet: WalletConnectURL?
|
||
|
||
init(ndb: Ndb, keypair: Keypair, bootstrapRelays: [RelayURL]) {
|
||
self.ndb = ndb
|
||
self.keypair = keypair
|
||
self.bootstrapRelays = bootstrapRelays
|
||
self.relayModelCache = RelayModelCache()
|
||
self.relayFilters = RelayFilters(our_pubkey: keypair.pubkey)
|
||
}
|
||
}
|