This adds a sync mechanism in Ndb.swift to coordinate certain usage of nostrdb.c calls and the need to close nostrdb due to app lifecycle requirements. Furthermore, it fixes the order of operations when re-opening NostrDB, to avoid race conditions where a query uses an older Ndb generation. This sync mechanism allows multiple queries to happen simultaneously (from the Swift-side), while preventing ndb from simultaneously closing during such usages. It also does that while keeping the Ndb interface sync and nonisolated, which keeps the API easy to use from Swift/SwiftUI and allows for parallel operations to occur. If Swift Actors were to be used (e.g. creating an NdbActor), the Ndb.swift interface would change in such a way that it would propagate the need for several changes throughout the codebase, including loading logic in some ViewModels. Furthermore, it would likely decrease performance by forcing Ndb.swift operations to run sequentially when they could run in parallel. Changelog-Fixed: Fixed crashes that happened when the app went into background mode Closes: https://github.com/damus-io/damus/issues/3245 Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
251 lines
9.5 KiB
Swift
251 lines
9.5 KiB
Swift
//
|
|
// ProfilesManagerTests.swift
|
|
// damusTests
|
|
//
|
|
// Created by alltheseas on 2025-12-18.
|
|
//
|
|
|
|
import XCTest
|
|
@testable import damus
|
|
|
|
|
|
class ProfilesManagerTests: XCTestCase {
|
|
|
|
/// Tests that streamProfile with yieldCached: true (default) immediately emits a cached profile.
|
|
///
|
|
/// This verifies the fix for missing profile names/pictures: when a view subscribes to
|
|
/// profile updates, it should immediately receive any cached profile data from NostrDB
|
|
/// rather than waiting for the network subscription to restart (~1 second delay).
|
|
func testStreamProfileYieldsCachedProfileByDefault() async throws {
|
|
let ndb = Ndb.test
|
|
defer { ndb.close() }
|
|
|
|
// Seed a profile into NDB
|
|
let profilePubkey = test_keypair.pubkey
|
|
let profileJson = """
|
|
{"name":"testuser","display_name":"Test User","about":"A test profile","picture":"https://example.com/pic.jpg"}
|
|
"""
|
|
let metadataEvent = NostrEvent(
|
|
content: profileJson,
|
|
keypair: test_keypair,
|
|
kind: NostrKind.metadata.rawValue,
|
|
tags: [],
|
|
createdAt: UInt32(Date.now.timeIntervalSince1970)
|
|
)!
|
|
|
|
let eventJson = encode_json(metadataEvent)!
|
|
let relayMessage = "[\"EVENT\",\"subid\",\(eventJson)]"
|
|
let processed = ndb.processEvent(relayMessage)
|
|
XCTAssertTrue(processed, "Failed to process metadata event")
|
|
|
|
// Give NDB time to process
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Verify profile is in NDB
|
|
let cachedProfile = try? ndb.lookup_profile_and_copy(profilePubkey)
|
|
XCTAssertNotNil(cachedProfile, "Profile should be cached in NDB")
|
|
XCTAssertEqual(cachedProfile?.name, "testuser")
|
|
|
|
// Create ProfilesManager and test streaming
|
|
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
|
|
let subscriptionManager = NostrNetworkManager.SubscriptionManager(
|
|
pool: pool,
|
|
ndb: ndb,
|
|
experimentalLocalRelayModelSupport: false
|
|
)
|
|
let profilesManager = NostrNetworkManager.ProfilesManager(
|
|
subscriptionManager: subscriptionManager,
|
|
ndb: ndb
|
|
)
|
|
|
|
// Test: yieldCached: true (default) should emit immediately
|
|
let receivedProfile = XCTestExpectation(description: "Should receive cached profile immediately")
|
|
var emittedProfile: NdbProfile?
|
|
|
|
Task {
|
|
// Default yieldCached: true
|
|
for await profile in await profilesManager.streamProfile(pubkey: profilePubkey) {
|
|
emittedProfile = profile
|
|
receivedProfile.fulfill()
|
|
break // Only need the first emission
|
|
}
|
|
}
|
|
|
|
// Should receive the profile very quickly (not waiting for network)
|
|
await fulfillment(of: [receivedProfile], timeout: 0.5)
|
|
|
|
XCTAssertNotNil(emittedProfile, "Should have received a profile")
|
|
XCTAssertEqual(emittedProfile?.name, "testuser", "Should receive the cached profile data")
|
|
XCTAssertEqual(emittedProfile?.display_name, "Test User")
|
|
}
|
|
|
|
/// Tests that streamProfile with yieldCached: false does NOT immediately emit cached profiles.
|
|
///
|
|
/// This is used by callers like NoteContentView that only need network updates, not
|
|
/// initial cached state, to avoid redundant artifact re-renders.
|
|
func testStreamProfileOptOutSkipsCachedProfile() async throws {
|
|
let ndb = Ndb.test
|
|
defer { ndb.close() }
|
|
|
|
// Seed a profile into NDB
|
|
let profilePubkey = test_keypair.pubkey
|
|
let profileJson = """
|
|
{"name":"testuser","display_name":"Test User","about":"A test profile"}
|
|
"""
|
|
let metadataEvent = NostrEvent(
|
|
content: profileJson,
|
|
keypair: test_keypair,
|
|
kind: NostrKind.metadata.rawValue,
|
|
tags: [],
|
|
createdAt: UInt32(Date.now.timeIntervalSince1970)
|
|
)!
|
|
|
|
let eventJson = encode_json(metadataEvent)!
|
|
let relayMessage = "[\"EVENT\",\"subid\",\(eventJson)]"
|
|
let processed = ndb.processEvent(relayMessage)
|
|
XCTAssertTrue(processed, "Failed to process metadata event")
|
|
|
|
// Give NDB time to process
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Verify profile is in NDB
|
|
let cachedProfile = try? ndb.lookup_profile_and_copy(profilePubkey)
|
|
XCTAssertNotNil(cachedProfile, "Profile should be cached in NDB")
|
|
|
|
// Create ProfilesManager
|
|
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
|
|
let subscriptionManager = NostrNetworkManager.SubscriptionManager(
|
|
pool: pool,
|
|
ndb: ndb,
|
|
experimentalLocalRelayModelSupport: false
|
|
)
|
|
let profilesManager = NostrNetworkManager.ProfilesManager(
|
|
subscriptionManager: subscriptionManager,
|
|
ndb: ndb
|
|
)
|
|
|
|
// Test: yieldCached: false should NOT emit immediately
|
|
let shouldNotReceive = XCTestExpectation(description: "Should NOT receive cached profile")
|
|
shouldNotReceive.isInverted = true // We expect this to NOT be fulfilled
|
|
|
|
Task {
|
|
// Explicitly opt out of cached emission
|
|
for await _ in await profilesManager.streamProfile(pubkey: profilePubkey, yieldCached: false) {
|
|
shouldNotReceive.fulfill() // This should NOT happen
|
|
break
|
|
}
|
|
}
|
|
|
|
// Wait briefly - the stream should NOT emit anything
|
|
await fulfillment(of: [shouldNotReceive], timeout: 0.3)
|
|
// If we get here without the expectation being fulfilled, the test passes
|
|
}
|
|
|
|
/// Tests that streamProfiles with yieldCached: true emits all cached profiles.
|
|
func testStreamProfilesYieldsCachedProfilesByDefault() async throws {
|
|
let ndb = Ndb.test
|
|
defer { ndb.close() }
|
|
|
|
// Seed two profiles into NDB
|
|
let pubkey1 = test_keypair.pubkey
|
|
let pubkey2 = test_pubkey_2
|
|
|
|
let profile1Json = "{\"name\":\"user1\",\"display_name\":\"User One\"}"
|
|
let profile1Event = NostrEvent(
|
|
content: profile1Json,
|
|
keypair: test_keypair,
|
|
kind: NostrKind.metadata.rawValue,
|
|
tags: [],
|
|
createdAt: UInt32(Date.now.timeIntervalSince1970)
|
|
)!
|
|
|
|
// For pubkey2, we need to create an event that appears to come from that pubkey
|
|
// Since we can't sign for pubkey2, we'll just test with one profile
|
|
|
|
let eventJson = encode_json(profile1Event)!
|
|
let relayMessage = "[\"EVENT\",\"subid\",\(eventJson)]"
|
|
let processed = ndb.processEvent(relayMessage)
|
|
XCTAssertTrue(processed, "Failed to process metadata event")
|
|
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Create ProfilesManager
|
|
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
|
|
let subscriptionManager = NostrNetworkManager.SubscriptionManager(
|
|
pool: pool,
|
|
ndb: ndb,
|
|
experimentalLocalRelayModelSupport: false
|
|
)
|
|
let profilesManager = NostrNetworkManager.ProfilesManager(
|
|
subscriptionManager: subscriptionManager,
|
|
ndb: ndb
|
|
)
|
|
|
|
// Test: yieldCached: true (default) should emit cached profiles
|
|
let receivedProfile = XCTestExpectation(description: "Should receive cached profile")
|
|
var emittedProfiles: [NdbProfile] = []
|
|
|
|
Task {
|
|
// Request both pubkeys, but only pubkey1 has a profile in NDB
|
|
for await profile in await profilesManager.streamProfiles(pubkeys: Set([pubkey1, pubkey2])) {
|
|
emittedProfiles.append(profile)
|
|
receivedProfile.fulfill()
|
|
break
|
|
}
|
|
}
|
|
|
|
await fulfillment(of: [receivedProfile], timeout: 0.5)
|
|
|
|
XCTAssertEqual(emittedProfiles.count, 1, "Should receive one cached profile")
|
|
XCTAssertEqual(emittedProfiles.first?.name, "user1")
|
|
}
|
|
|
|
/// Tests that streamProfiles with yieldCached: false does NOT emit cached profiles.
|
|
func testStreamProfilesOptOutSkipsCachedProfiles() async throws {
|
|
let ndb = Ndb.test
|
|
defer { ndb.close() }
|
|
|
|
// Seed a profile into NDB
|
|
let profilePubkey = test_keypair.pubkey
|
|
let profileJson = "{\"name\":\"testuser\"}"
|
|
let metadataEvent = NostrEvent(
|
|
content: profileJson,
|
|
keypair: test_keypair,
|
|
kind: NostrKind.metadata.rawValue,
|
|
tags: [],
|
|
createdAt: UInt32(Date.now.timeIntervalSince1970)
|
|
)!
|
|
|
|
let eventJson = encode_json(metadataEvent)!
|
|
let relayMessage = "[\"EVENT\",\"subid\",\(eventJson)]"
|
|
ndb.processEvent(relayMessage)
|
|
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Create ProfilesManager
|
|
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
|
|
let subscriptionManager = NostrNetworkManager.SubscriptionManager(
|
|
pool: pool,
|
|
ndb: ndb,
|
|
experimentalLocalRelayModelSupport: false
|
|
)
|
|
let profilesManager = NostrNetworkManager.ProfilesManager(
|
|
subscriptionManager: subscriptionManager,
|
|
ndb: ndb
|
|
)
|
|
|
|
// Test: yieldCached: false should NOT emit
|
|
let shouldNotReceive = XCTestExpectation(description: "Should NOT receive cached profiles")
|
|
shouldNotReceive.isInverted = true
|
|
|
|
Task {
|
|
for await _ in await profilesManager.streamProfiles(pubkeys: Set([profilePubkey]), yieldCached: false) {
|
|
shouldNotReceive.fulfill()
|
|
break
|
|
}
|
|
}
|
|
|
|
await fulfillment(of: [shouldNotReceive], timeout: 0.3)
|
|
}
|
|
}
|