Files
damus/damusTests/NegentropySupportTests.swift
Daniel D’Aquino 95d38fa802 Implement initial negentropy base functions
This implements some useful functions to use negentropy from RelayPool,
but does not integrate them with the rest of the app.

No changelog for the negentropy support right now as it is not hooked up
to any user-facing feature

Changelog-Fixed: Fixed a race condition in the networking logic that could cause notes to get missed in certain rare scenarios
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-22 14:20:57 -08:00

446 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// NegentropySupportTests.swift
// damus
//
// Created by Daniel DAquino on 2026-01-12.
//
import XCTest
import NostrSDK
import Negentropy
@testable import damus
final class NegentropySupportTests: XCTestCase {
// MARK: - Helper Functions
/// Creates and runs a local relay on the specified port.
/// - Parameter port: The port number to run the relay on
/// - Returns: The running LocalRelay instance
private func setupRelay(port: UInt16) async throws -> LocalRelay {
let builder = RelayBuilder().port(port: port)
let relay = LocalRelay(builder: builder)
try await relay.run()
print("Relay url: \(await relay.url())")
return relay
}
/// 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 (e.g., "Relay1", "Relay2")
/// - 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("NEGENTROPY_SUPPORT_TEST \(prefix): Received: \(message)")
case .disconnected(let closeCode, let string):
print("NEGENTROPY_SUPPORT_TEST \(prefix): Disconnected: \(closeCode); \(String(describing: string))")
case .error(let error):
print("NEGENTROPY_SUPPORT_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)))
}
}
/// Sets up a relay pool with the specified relay URLs.
/// - Parameter urls: Array of RelayURL to add to the pool
/// - Returns: Configured and connected RelayPool
private func setupRelayPool(with urls: [RelayURL]) async throws -> RelayPool {
let relayPool = RelayPool(ndb: await test_damus_state.ndb)
for url in urls {
try await relayPool.add_relay(.init(url: url, info: .readWrite))
}
await relayPool.connect()
// Wait for relay pool to be ready.
// It's generally not a good idea to hard code delays but RelayPool does not seem to provide any way to await for the connection to fully go through,
// or that mechanism is not well documented.
try await Task.sleep(for: .seconds(2))
return relayPool
}
/// Runs a negentropy subscribe operation and fulfills expectations based on received events.
/// - Parameters:
/// - relayPool: The relay pool to subscribe through
/// - filters: The NostrFilters to apply
/// - vector: The NegentropyStorageVector representing local state
/// - eventExpectations: Dictionary mapping event IDs to their expectations
/// - ignoreUnsupportedRelays: Whether to ignore relays that don't support negentropy
private func runNegentropySubscribe(
relayPool: RelayPool,
filters: [NostrFilter],
vector: NegentropyStorageVector,
eventExpectations: [NoteId: XCTestExpectation],
ignoreUnsupportedRelays: Bool = false
) {
Task {
do {
for try await item in try await relayPool.negentropySubscribe(
filters: filters,
negentropyVector: vector,
ignoreUnsupportedRelays: ignoreUnsupportedRelays
) {
switch item {
case .event(let event):
if let expectation = eventExpectations[event.id] {
expectation.fulfill()
}
case .eose:
return
}
}
}
catch {
XCTFail("Stream Error: \(error)")
}
}
}
// MARK: - Test Cases
func testBasic() async throws {
// Given: A relay with noteA and noteB, and local storage has noteA
let relay = try await setupRelay(port: 8080) // Do not discard the result to avoid relay from being garbage collected and shutdown
let relayUrl = RelayURL(await relay.url().description)!
let noteA = NostrEvent(content: "A", keypair: test_keypair)!
let noteB = NostrEvent(content: "B", keypair: test_keypair)!
let relayConnection = await connectToRelay(url: relayUrl)
sendEvents([noteA, noteB], to: relayConnection)
let relayPool = try await setupRelayPool(with: [relayUrl])
let negentropyVector = NegentropyStorageVector()
try negentropyVector.insert(nostrEvent: noteA)
let getsNoteB = XCTestExpectation(description: "Gets note B")
let doesNotGetNoteA = XCTestExpectation(description: "Does not get note A")
doesNotGetNoteA.isInverted = true
// When: Performing negentropy subscribe
runNegentropySubscribe(
relayPool: relayPool,
filters: [NostrFilter(kinds: [.text])],
vector: negentropyVector,
eventExpectations: [noteA.id: doesNotGetNoteA, noteB.id: getsNoteB]
)
// Then: Should receive only noteB (noteA is already synced)
await fulfillment(of: [getsNoteB, doesNotGetNoteA], timeout: 5.0)
}
func testEmptyLocalStorage() async throws {
// Given: A relay with noteA and noteB, and empty local storage
let relay = try await setupRelay(port: 8081)
let relayUrl = RelayURL(await relay.url().description)!
let noteA = NostrEvent(content: "A", keypair: test_keypair)!
let noteB = NostrEvent(content: "B", keypair: test_keypair)!
let relayConnection = await connectToRelay(url: relayUrl)
sendEvents([noteA, noteB], to: relayConnection)
let relayPool = try await setupRelayPool(with: [relayUrl])
// Empty negentropy vector - should receive all events
let negentropyVector = NegentropyStorageVector()
let getsNoteA = XCTestExpectation(description: "Gets note A")
let getsNoteB = XCTestExpectation(description: "Gets note B")
// When: Performing negentropy subscribe with empty local storage
runNegentropySubscribe(
relayPool: relayPool,
filters: [NostrFilter(kinds: [.text])],
vector: negentropyVector,
eventExpectations: [noteA.id: getsNoteA, noteB.id: getsNoteB]
)
// Then: Should receive all events (noteA and noteB)
await fulfillment(of: [getsNoteA, getsNoteB], timeout: 5.0)
}
/// Test negentropy sync with two relays having overlapping events.
/// Relay1 has noteA+noteB, Relay2 has noteB+noteC, local has noteB.
/// Should get noteA from Relay1 and noteC from Relay2 (deduplicating noteB).
func testTwoRelaysWithOverlap() async throws {
// Given: Two relays with overlapping events and local storage has noteB
let relay1 = try await setupRelay(port: 8082)
let relay2 = try await setupRelay(port: 8083)
let relayUrl1 = RelayURL(await relay1.url().description)!
let relayUrl2 = RelayURL(await relay2.url().description)!
let noteA = NostrEvent(content: "A", keypair: test_keypair)!
let noteB = NostrEvent(content: "B", keypair: test_keypair)!
let noteC = NostrEvent(content: "C", keypair: test_keypair)!
// Connect to relay1 and send noteA + noteB
let relayConnection1 = await connectToRelay(url: relayUrl1, label: "Relay1")
sendEvents([noteA, noteB], to: relayConnection1)
// Connect to relay2 and send noteB + noteC
let relayConnection2 = await connectToRelay(url: relayUrl2, label: "Relay2")
sendEvents([noteB, noteC], to: relayConnection2)
let relayPool = try await setupRelayPool(with: [relayUrl1, relayUrl2])
// Local vector has noteB already
let negentropyVector = NegentropyStorageVector()
try negentropyVector.insert(nostrEvent: noteB)
let getsNoteA = XCTestExpectation(description: "Gets note A")
let getsNoteC = XCTestExpectation(description: "Gets note C")
let doesNotGetNoteB = XCTestExpectation(description: "Does not get note B")
doesNotGetNoteB.isInverted = true
// When: Performing negentropy subscribe across two relays
runNegentropySubscribe(
relayPool: relayPool,
filters: [NostrFilter(kinds: [.text])],
vector: negentropyVector,
eventExpectations: [noteA.id: getsNoteA, noteB.id: doesNotGetNoteB, noteC.id: getsNoteC]
)
// Then: Should receive noteA and noteC, but not noteB (already synced)
await fulfillment(of: [getsNoteA, getsNoteC, doesNotGetNoteB], timeout: 5.0)
}
/// Test negentropy sync when all events are already synced locally.
/// Local has noteA+noteB, relay has noteA+noteB.
/// Should receive EOSE only without any events.
func testAllEventsSynced() async throws {
// Given: A relay with noteA and noteB, and local storage has both events
let relay = try await setupRelay(port: 8084)
let relayUrl = RelayURL(await relay.url().description)!
let noteA = NostrEvent(content: "A", keypair: test_keypair)!
let noteB = NostrEvent(content: "B", keypair: test_keypair)!
let relayConnection = await connectToRelay(url: relayUrl)
sendEvents([noteA, noteB], to: relayConnection)
let relayPool = try await setupRelayPool(with: [relayUrl])
// Local vector has both events already
let negentropyVector = NegentropyStorageVector()
try negentropyVector.insert(nostrEvent: noteA)
try negentropyVector.insert(nostrEvent: noteB)
let doesNotGetNoteA = XCTestExpectation(description: "Does not get note A")
let doesNotGetNoteB = XCTestExpectation(description: "Does not get note B")
doesNotGetNoteA.isInverted = true
doesNotGetNoteB.isInverted = true
// When: Performing negentropy subscribe with all events already synced
runNegentropySubscribe(
relayPool: relayPool,
filters: [NostrFilter(kinds: [.text])],
vector: negentropyVector,
eventExpectations: [noteA.id: doesNotGetNoteA, noteB.id: doesNotGetNoteB]
)
// Then: Should not receive any events (all already synced)
await fulfillment(of: [doesNotGetNoteA, doesNotGetNoteB], timeout: 5.0)
}
/// Test negentropy sync when local storage is a superset of relay events.
/// Local has noteA+noteB+noteC, relay has noteA+noteB.
/// Should receive no new events.
func testRelaySubset() async throws {
// Given: A relay with noteA and noteB, and local storage has noteA, noteB, and noteC
let relay = try await setupRelay(port: 8085)
let relayUrl = RelayURL(await relay.url().description)!
let noteA = NostrEvent(content: "A", keypair: test_keypair)!
let noteB = NostrEvent(content: "B", keypair: test_keypair)!
let noteC = NostrEvent(content: "C", keypair: test_keypair)!
let relayConnection = await connectToRelay(url: relayUrl)
sendEvents([noteA, noteB], to: relayConnection)
let relayPool = try await setupRelayPool(with: [relayUrl])
// Local vector has all relay events plus one more
let negentropyVector = NegentropyStorageVector()
try negentropyVector.insert(nostrEvent: noteA)
try negentropyVector.insert(nostrEvent: noteB)
try negentropyVector.insert(nostrEvent: noteC)
let doesNotGetNoteA = XCTestExpectation(description: "Does not get note A")
let doesNotGetNoteB = XCTestExpectation(description: "Does not get note B")
let doesNotGetNoteC = XCTestExpectation(description: "Does not get note C")
doesNotGetNoteA.isInverted = true
doesNotGetNoteB.isInverted = true
doesNotGetNoteC.isInverted = true
// When: Performing negentropy subscribe where local is a superset of relay
runNegentropySubscribe(
relayPool: relayPool,
filters: [NostrFilter(kinds: [.text])],
vector: negentropyVector,
eventExpectations: [noteA.id: doesNotGetNoteA, noteB.id: doesNotGetNoteB, noteC.id: doesNotGetNoteC]
)
// Then: Should not receive any events (local has all relay events and more)
await fulfillment(of: [doesNotGetNoteA, doesNotGetNoteB, doesNotGetNoteC], timeout: 5.0)
}
/// Test negentropy sync with three relays having overlapping events and partial local sync.
/// Relay1 has A+B, Relay2 has B+C, Relay3 has C+D, local has A+C.
/// Should only receive B and D.
func testThreeRelaysPartialSync() async throws {
// Given: Three relays with overlapping events and local storage has noteA and noteC
let relay1 = try await setupRelay(port: 8086)
let relay2 = try await setupRelay(port: 8087)
let relay3 = try await setupRelay(port: 8088)
let relayUrl1 = RelayURL(await relay1.url().description)!
let relayUrl2 = RelayURL(await relay2.url().description)!
let relayUrl3 = RelayURL(await relay3.url().description)!
let noteA = NostrEvent(content: "A", keypair: test_keypair)!
let noteB = NostrEvent(content: "B", keypair: test_keypair)!
let noteC = NostrEvent(content: "C", keypair: test_keypair)!
let noteD = NostrEvent(content: "D", keypair: test_keypair)!
// Connect to relay1 and send noteA + noteB
let relayConnection1 = await connectToRelay(url: relayUrl1, label: "Relay1")
sendEvents([noteA, noteB], to: relayConnection1)
// Connect to relay2 and send noteB + noteC
let relayConnection2 = await connectToRelay(url: relayUrl2, label: "Relay2")
sendEvents([noteB, noteC], to: relayConnection2)
// Connect to relay3 and send noteC + noteD
let relayConnection3 = await connectToRelay(url: relayUrl3, label: "Relay3")
sendEvents([noteC, noteD], to: relayConnection3)
let relayPool = try await setupRelayPool(with: [relayUrl1, relayUrl2, relayUrl3])
// Local vector has noteA and noteC already
let negentropyVector = NegentropyStorageVector()
try negentropyVector.insert(nostrEvent: noteA)
try negentropyVector.insert(nostrEvent: noteC)
let getsNoteB = XCTestExpectation(description: "Gets note B")
let getsNoteD = XCTestExpectation(description: "Gets note D")
let doesNotGetNoteA = XCTestExpectation(description: "Does not get note A")
let doesNotGetNoteC = XCTestExpectation(description: "Does not get note C")
doesNotGetNoteA.isInverted = true
doesNotGetNoteC.isInverted = true
// When: Performing negentropy subscribe across three relays with partial overlap
runNegentropySubscribe(
relayPool: relayPool,
filters: [NostrFilter(kinds: [.text])],
vector: negentropyVector,
eventExpectations: [
noteA.id: doesNotGetNoteA,
noteB.id: getsNoteB,
noteC.id: doesNotGetNoteC,
noteD.id: getsNoteD
]
)
// Then: Should receive only noteB and noteD (noteA and noteC already synced)
await fulfillment(of: [getsNoteB, getsNoteD, doesNotGetNoteA, doesNotGetNoteC], timeout: 5.0)
}
/// Test negentropy sync with multiple filters for different event kinds across three relays.
/// Relay1 has text notes A+B (kind 1), Relay2 has text B + DM C (kind 4), Relay3 has DMs C+D (kind 4).
/// Local has text note A (kind 1) and DM C (kind 4).
/// Uses two filters: one for kind 1 (text), one for kind 4 (DMs).
/// Should only receive text note B and DM D.
func testMultipleFiltersWithDifferentKinds() async throws {
// Given: Three relays with mixed event kinds and local storage has text note A and DM C
let relay1 = try await setupRelay(port: 8089)
let relay2 = try await setupRelay(port: 8090)
let relay3 = try await setupRelay(port: 8091)
let relayUrl1 = RelayURL(await relay1.url().description)!
let relayUrl2 = RelayURL(await relay2.url().description)!
let relayUrl3 = RelayURL(await relay3.url().description)!
// Create events with different kinds
// kind 1 = text notes, kind 4 = encrypted DMs
let noteA = NostrEvent(content: "A", keypair: test_keypair, kind: 1)! // text note
let noteB = NostrEvent(content: "B", keypair: test_keypair, kind: 1)! // text note
let noteC = NostrEvent(content: "C", keypair: test_keypair, kind: 4)! // DM
let noteD = NostrEvent(content: "D", keypair: test_keypair, kind: 4)! // DM
// Connect to relay1 and send text notes A + B
let relayConnection1 = await connectToRelay(url: relayUrl1, label: "Relay1")
sendEvents([noteA, noteB], to: relayConnection1)
// Connect to relay2 and send text note B + DM C
let relayConnection2 = await connectToRelay(url: relayUrl2, label: "Relay2")
sendEvents([noteB, noteC], to: relayConnection2)
// Connect to relay3 and send DMs C + D
let relayConnection3 = await connectToRelay(url: relayUrl3, label: "Relay3")
sendEvents([noteC, noteD], to: relayConnection3)
let relayPool = try await setupRelayPool(with: [relayUrl1, relayUrl2, relayUrl3])
// Local vector has text note A and DM C already
let negentropyVector = NegentropyStorageVector()
try negentropyVector.insert(nostrEvent: noteA)
try negentropyVector.insert(nostrEvent: noteC)
let getsNoteB = XCTestExpectation(description: "Gets text note B")
let getsNoteD = XCTestExpectation(description: "Gets DM D")
let doesNotGetNoteA = XCTestExpectation(description: "Does not get text note A")
let doesNotGetNoteC = XCTestExpectation(description: "Does not get DM C")
doesNotGetNoteA.isInverted = true
doesNotGetNoteC.isInverted = true
// When: Performing negentropy subscribe with multiple filters for different kinds
// Use two filters: one for kind 1 (text), one for kind 4 (DMs)
runNegentropySubscribe(
relayPool: relayPool,
filters: [
NostrFilter(kinds: [.text]), // kind 1
NostrFilter(kinds: [.dm]) // kind 4
],
vector: negentropyVector,
eventExpectations: [
noteA.id: doesNotGetNoteA,
noteB.id: getsNoteB,
noteC.id: doesNotGetNoteC,
noteD.id: getsNoteD
]
)
// Then: Should receive only text note B and DM D (text note A and DM C already synced)
await fulfillment(of: [getsNoteB, getsNoteD, doesNotGetNoteA, doesNotGetNoteC], timeout: 5.0)
}
}