refactor(geode): test fixtures + collectUntilEose helper, move to testFixtures sourceSet

The in-process relay was usable but every consumer test paid a 10–15
line tax for scope/client setup, manual cleanup inside the test body
(leaks on assertion failure), hardcoded "ws://127.0.0.1:7770/" magic
strings, and an inline collectUntilEose pattern reinvented per file.

Net: ~250 LOC removed, every test gets correct @After cleanup, and
the synthetic event builders no longer ship in geode's production jar.

- Move geode.fixtures (synthetic event builders) and the new
  geode.testing package from src/main to a new src/testFixtures
  source set via the java-test-fixtures plugin. Production geode jar
  no longer includes them; consumers wire them with
  testImplementation(testFixtures(project(":geode"))).
- Add geode.testing.RelayClientTest open class — owns hub, scope,
  client, defaultRelay, defaultRelayUrl with @After-driven cleanup.
- Add geode.testing.collectUntilEose / collectUntilEoseMulti
  NostrClient extensions with a shared subId counter and timeout knob.
  Replaces hand-rolled subscriber loops in 4+ files.
- Add RelayHub.DEFAULT_URL constant so tests stop typing
  "ws://127.0.0.1:7770/" inline.
- Migrate the 8 quartz NostrClient*Test files + geode's
  Nip01ComplianceTest to extend RelayClientTest and (where REQ/EOSE
  is the pattern) call collectUntilEose. Delete the redundant
  BaseNostrClientTest wrapper.
This commit is contained in:
Claude
2026-05-07 13:42:20 +00:00
parent c2f24a5213
commit c19bd4e92e
18 changed files with 342 additions and 493 deletions
+17
View File
@@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.jetbrainsKotlinJvm)
alias(libs.plugins.serialization) alias(libs.plugins.serialization)
application application
`java-test-fixtures`
} }
application { application {
@@ -25,6 +26,15 @@ sourceSets {
test { test {
kotlin.srcDir("src/test/kotlin") kotlin.srcDir("src/test/kotlin")
} }
// The `java-test-fixtures` plugin auto-creates a `testFixtures`
// source set; we just point it at our Kotlin layout so the
// `geode.fixtures` (synthetic events) and `geode.testing`
// (RelayClientTest, collectUntilEose) packages don't ship in
// production jars but are still usable by every consumer's test
// source via `testImplementation(testFixtures(project(":geode")))`.
named("testFixtures") {
kotlin.srcDir("src/testFixtures/kotlin")
}
} }
tasks.withType<Test>().configureEach { tasks.withType<Test>().configureEach {
@@ -63,6 +73,13 @@ dependencies {
// port their configs nearly verbatim. // port their configs nearly verbatim.
implementation(libs.fourkoma) implementation(libs.fourkoma)
// testFixtures: code in src/testFixtures/kotlin (RelayClientTest +
// synthetic event builders). Not shipped in the production jar but
// exposed to consumers via testImplementation(testFixtures(...)).
testFixturesApi(project(":quartz"))
testFixturesApi(libs.junit)
testFixturesImplementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlin.test) testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm) testImplementation(libs.secp256k1.kmp.jni.jvm)
@@ -86,4 +86,16 @@ class RelayHub(
relays.values.forEach { runCatching { it.close() } } relays.values.forEach { runCatching { it.close() } }
relays.clear() relays.clear()
} }
companion object {
/**
* Default URL for tests that only need one relay. The URL itself
* has no semantic meaning — it's just a stable key into the hub
* — but it normalises through [RelayUrlNormalizer] (loopback) so
* the production [com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer]
* accepts it. Prefer this over typing `"ws://127.0.0.1:7770/"`
* everywhere.
*/
val DEFAULT_URL: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
}
} }
@@ -21,26 +21,22 @@
package com.vitorpamplona.geode package com.vitorpamplona.geode
import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.geode.testing.collectUntilEose
import com.vitorpamplona.geode.testing.collectUntilEoseMulti
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull import kotlin.test.assertNull
@@ -62,29 +58,9 @@ import kotlin.test.assertTrue
* spec-compliant relay (nostr-rs-relay, strfry, khatru, …) — only the * spec-compliant relay (nostr-rs-relay, strfry, khatru, …) — only the
* `socketBuilder` and the relay URL would change. * `socketBuilder` and the relay URL would change.
*/ */
class Nip01ComplianceTest { class Nip01ComplianceTest : RelayClientTest() {
private lateinit var hub: RelayHub
private lateinit var scope: CoroutineScope
private lateinit var client: NostrClient
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@BeforeTest
fun setup() {
hub = RelayHub()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
client = NostrClient(hub, scope)
}
@AfterTest
fun teardown() {
client.disconnect()
scope.cancel()
hub.close()
}
private suspend fun preload(vararg events: Event) { private suspend fun preload(vararg events: Event) {
hub.getOrCreate(relayUrl).preload(*events) defaultRelay.preload(*events)
} }
private fun fakeEvent( private fun fakeEvent(
@@ -108,7 +84,7 @@ class Nip01ComplianceTest {
fakeEvent(3, kind = 1), fakeEvent(3, kind = 1),
) )
val (events, eose) = collectUntilEose(Filter(kinds = listOf(1))) val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1)))
assertEquals(2, events.size) assertEquals(2, events.size)
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
@@ -126,7 +102,7 @@ class Nip01ComplianceTest {
fakeEvent(4, kind = 1, createdAt = 400), fakeEvent(4, kind = 1, createdAt = 400),
) )
val (events, _) = collectUntilEose(Filter(kinds = listOf(1), limit = 2)) val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1), limit = 2))
assertEquals(2, events.size) assertEquals(2, events.size)
assertEquals(400L, events[0].createdAt) assertEquals(400L, events[0].createdAt)
@@ -145,7 +121,7 @@ class Nip01ComplianceTest {
fakeEvent(3, kind = 1, pubKey = alice), fakeEvent(3, kind = 1, pubKey = alice),
) )
val (events, _) = collectUntilEose(Filter(authors = listOf(alice))) val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(authors = listOf(alice)))
assertEquals(2, events.size) assertEquals(2, events.size)
assertTrue(events.all { it.pubKey == alice }) assertTrue(events.all { it.pubKey == alice })
@@ -157,7 +133,7 @@ class Nip01ComplianceTest {
runBlocking { runBlocking {
preload(fakeEvent(1), fakeEvent(2), fakeEvent(3)) preload(fakeEvent(1), fakeEvent(2), fakeEvent(3))
val (events, _) = collectUntilEose(Filter(ids = listOf(SyntheticEvents.hexId(2)))) val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(ids = listOf(SyntheticEvents.hexId(2))))
assertEquals(1, events.size) assertEquals(1, events.size)
assertEquals(SyntheticEvents.hexId(2), events[0].id) assertEquals(SyntheticEvents.hexId(2), events[0].id)
@@ -174,7 +150,7 @@ class Nip01ComplianceTest {
fakeEvent(4, createdAt = 400), fakeEvent(4, createdAt = 400),
) )
val (events, _) = collectUntilEose(Filter(since = 150L, until = 350L)) val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(since = 150L, until = 350L))
assertEquals(setOf(200L, 300L), events.map { it.createdAt }.toSet()) assertEquals(setOf(200L, 300L), events.map { it.createdAt }.toSet())
} }
@@ -190,7 +166,7 @@ class Nip01ComplianceTest {
fakeEvent(3, tags = arrayOf(arrayOf("e", target), arrayOf("p", SyntheticEvents.hexId(8)))), fakeEvent(3, tags = arrayOf(arrayOf("e", target), arrayOf("p", SyntheticEvents.hexId(8)))),
) )
val (events, _) = collectUntilEose(Filter(tags = mapOf("e" to listOf(target)))) val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("e" to listOf(target))))
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
} }
@@ -206,7 +182,7 @@ class Nip01ComplianceTest {
fakeEvent(3, tags = arrayOf(arrayOf("p", targetPubkey), arrayOf("e", SyntheticEvents.hexId(99)))), fakeEvent(3, tags = arrayOf(arrayOf("p", targetPubkey), arrayOf("e", SyntheticEvents.hexId(99)))),
) )
val (events, _) = collectUntilEose(Filter(tags = mapOf("p" to listOf(targetPubkey)))) val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("p" to listOf(targetPubkey))))
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
} }
@@ -221,7 +197,7 @@ class Nip01ComplianceTest {
fakeEvent(3, tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "kotlin"))), fakeEvent(3, tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "kotlin"))),
) )
val (events, _) = collectUntilEose(Filter(tags = mapOf("t" to listOf("nostr")))) val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("t" to listOf("nostr"))))
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
} }
@@ -241,7 +217,7 @@ class Nip01ComplianceTest {
fakeEvent(3, tags = arrayOf(arrayOf("e", SyntheticEvents.hexId(999)))), fakeEvent(3, tags = arrayOf(arrayOf("e", SyntheticEvents.hexId(999)))),
) )
val (events, _) = collectUntilEose(Filter(tags = mapOf("e" to listOf(a, b)))) val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("e" to listOf(a, b))))
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(2)), events.map { it.id }.toSet()) assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(2)), events.map { it.id }.toSet())
} }
@@ -262,7 +238,8 @@ class Nip01ComplianceTest {
) )
val (events, _) = val (events, _) =
collectUntilEoseMulti( client.collectUntilEoseMulti(
defaultRelayUrl,
listOf( listOf(
Filter(kinds = listOf(1)), Filter(kinds = listOf(1)),
Filter(kinds = listOf(7)), Filter(kinds = listOf(7)),
@@ -294,7 +271,7 @@ class Nip01ComplianceTest {
client.subscribe( client.subscribe(
"sub-A", "sub-A",
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener { object : SubscriptionListener {
override fun onEvent( override fun onEvent(
event: Event, event: Event,
@@ -315,7 +292,7 @@ class Nip01ComplianceTest {
) )
client.subscribe( client.subscribe(
"sub-B", "sub-B",
mapOf(relayUrl to listOf(Filter(kinds = listOf(4)))), mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(4)))),
object : SubscriptionListener { object : SubscriptionListener {
override fun onEvent( override fun onEvent(
event: Event, event: Event,
@@ -362,7 +339,7 @@ class Nip01ComplianceTest {
fakeEvent(2, kind = 0, pubKey = pubkey, createdAt = 200, content = "new"), fakeEvent(2, kind = 0, pubKey = pubkey, createdAt = 200, content = "new"),
) )
val (events, _) = collectUntilEose(Filter(kinds = listOf(0), authors = listOf(pubkey))) val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(0), authors = listOf(pubkey)))
assertEquals(1, events.size) assertEquals(1, events.size)
assertEquals("new", events[0].content) assertEquals("new", events[0].content)
@@ -383,7 +360,11 @@ class Nip01ComplianceTest {
val v3 = signer.sign(LongTextNoteEvent.build("list-b", "title", dTag = "list-b", createdAt = 100)) val v3 = signer.sign(LongTextNoteEvent.build("list-b", "title", dTag = "list-b", createdAt = 100))
preload(v1, v2, v3) preload(v1, v2, v3)
val (events, _) = collectUntilEose(Filter(kinds = listOf(LongTextNoteEvent.KIND), authors = listOf(signer.pubKey))) val (events, _) =
client.collectUntilEose(
defaultRelayUrl,
Filter(kinds = listOf(LongTextNoteEvent.KIND), authors = listOf(signer.pubKey)),
)
assertEquals(2, events.size) assertEquals(2, events.size)
assertEquals(setOf("new", "list-b"), events.map { it.content }.toSet()) assertEquals(setOf("new", "list-b"), events.map { it.content }.toSet())
@@ -399,7 +380,7 @@ class Nip01ComplianceTest {
val gotEose = Channel<Unit>(UNLIMITED) val gotEose = Channel<Unit>(UNLIMITED)
client.subscribe( client.subscribe(
"live-1", "live-1",
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener { object : SubscriptionListener {
override fun onEvent( override fun onEvent(
event: Event, event: Event,
@@ -423,7 +404,7 @@ class Nip01ComplianceTest {
// Inject an event through the wire path (not preload — that bypasses // Inject an event through the wire path (not preload — that bypasses
// the live broadcast that subscriptions feed off of). // the live broadcast that subscriptions feed off of).
hub.getOrCreate(relayUrl).publish(fakeEvent(99, kind = 1, content = "live")) defaultRelay.publish(fakeEvent(99, kind = 1, content = "live"))
val received = withTimeout(5000) { ch.receive() } val received = withTimeout(5000) { ch.receive() }
assertEquals("live", received.content) assertEquals("live", received.content)
@@ -438,7 +419,7 @@ class Nip01ComplianceTest {
val gotEose = Channel<Unit>(UNLIMITED) val gotEose = Channel<Unit>(UNLIMITED)
client.subscribe( client.subscribe(
"live-2", "live-2",
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener { object : SubscriptionListener {
override fun onEvent( override fun onEvent(
event: Event, event: Event,
@@ -459,7 +440,7 @@ class Nip01ComplianceTest {
) )
withTimeout(5000) { gotEose.receive() } withTimeout(5000) { gotEose.receive() }
hub.getOrCreate(relayUrl).publish(fakeEvent(98, kind = 4, content = "off-topic")) defaultRelay.publish(fakeEvent(98, kind = 4, content = "off-topic"))
val seen = withTimeoutOrNull(500) { ch.receive() } val seen = withTimeoutOrNull(500) { ch.receive() }
assertNull(seen, "kind 4 should not match a kind-1 subscription") assertNull(seen, "kind 4 should not match a kind-1 subscription")
@@ -479,7 +460,7 @@ class Nip01ComplianceTest {
val gotEose = Channel<Unit>(UNLIMITED) val gotEose = Channel<Unit>(UNLIMITED)
client.subscribe( client.subscribe(
"eph-1", "eph-1",
mapOf(relayUrl to listOf(Filter(kinds = listOf(20_001)))), mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(20_001)))),
object : SubscriptionListener { object : SubscriptionListener {
override fun onEvent( override fun onEvent(
event: Event, event: Event,
@@ -500,7 +481,7 @@ class Nip01ComplianceTest {
) )
withTimeout(5000) { gotEose.receive() } withTimeout(5000) { gotEose.receive() }
hub.getOrCreate(relayUrl).publish(fakeEvent(70, kind = 20_001, content = "ephemeral-payload")) defaultRelay.publish(fakeEvent(70, kind = 20_001, content = "ephemeral-payload"))
val received = withTimeout(5000) { ch.receive() } val received = withTimeout(5000) { ch.receive() }
assertEquals("ephemeral-payload", received.content) assertEquals("ephemeral-payload", received.content)
@@ -516,10 +497,10 @@ class Nip01ComplianceTest {
fun ephemeralEventIsNotStoredAndDoesNotShowOnFollowupReq() = fun ephemeralEventIsNotStoredAndDoesNotShowOnFollowupReq() =
runBlocking { runBlocking {
// Publish ephemeral first — no live subscriber listening. // Publish ephemeral first — no live subscriber listening.
hub.getOrCreate(relayUrl).publish(fakeEvent(71, kind = 20_002, content = "vanish")) defaultRelay.publish(fakeEvent(71, kind = 20_002, content = "vanish"))
// Late subscriber: should see EOSE with no events. // Late subscriber: should see EOSE with no events.
val (events, eose) = collectUntilEose(Filter(kinds = listOf(20_002))) val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(20_002)))
assertTrue(eose, "EOSE must fire for an ephemeral kind even if zero events match") assertTrue(eose, "EOSE must fire for an ephemeral kind even if zero events match")
assertEquals(0, events.size, "Ephemeral events must not be persisted") assertEquals(0, events.size, "Ephemeral events must not be persisted")
} }
@@ -570,93 +551,4 @@ class Nip01ComplianceTest {
assertEquals("from-a", received[relayA]) assertEquals("from-a", received[relayA])
assertEquals("from-b", received[relayB]) assertEquals("from-b", received[relayB])
} }
// -- Helpers ------------------------------------------------------------
/** Subscribes synchronously and returns the events received before EOSE. */
private suspend fun collectUntilEose(filter: Filter): Pair<List<Event>, Boolean> {
val ch = Channel<Either>(UNLIMITED)
val subId = "sub-${System.nanoTime()}"
client.subscribe(
subId,
mapOf(relayUrl to listOf(filter)),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Either.Ev(event))
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Either.Eose)
}
},
)
val events = mutableListOf<Event>()
var eose = false
withTimeout(5000) {
while (!eose) {
when (val msg = ch.receive()) {
is Either.Ev -> events += msg.event
Either.Eose -> eose = true
}
}
}
client.unsubscribe(subId)
return events to eose
}
/** Variant of [collectUntilEose] that subscribes with multiple filters. */
private suspend fun collectUntilEoseMulti(filters: List<Filter>): Pair<List<Event>, Boolean> {
val ch = Channel<Either>(UNLIMITED)
val subId = "sub-${System.nanoTime()}"
client.subscribe(
subId,
mapOf(relayUrl to filters),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Either.Ev(event))
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Either.Eose)
}
},
)
val events = mutableListOf<Event>()
var eose = false
withTimeout(5000) {
while (!eose) {
when (val msg = ch.receive()) {
is Either.Ev -> events += msg.event
Either.Eose -> eose = true
}
}
}
client.unsubscribe(subId)
return events to eose
}
private sealed interface Either {
data class Ev(
val event: Event,
) : Either
object Eose : Either
}
} }
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.testing
import com.vitorpamplona.geode.Relay
import com.vitorpamplona.geode.RelayHub
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import org.junit.After
/**
* Base class for tests that drive a real [NostrClient] against an
* in-process [RelayHub]. Owns the lifecycle of the four pieces every
* such test needs:
*
* - [hub] — the registry of in-process relays (also serves as
* `WebsocketBuilder` for [NostrClient]).
* - [scope] — application coroutine scope for the client.
* - [client] — a [NostrClient] wired to [hub] and [scope].
* - [defaultRelay] / [defaultRelayUrl] — convenience handles for the
* single-relay case (the most common in tests).
*
* Cleanup happens in [tearDownRelayClientTest], registered with
* JUnit's [@After][After], so an assertion failure does NOT leak the
* scope, the SQLite event store, or the WebSocket bridge — a recurring
* problem with the previous "clean up at the end of the test body"
* pattern.
*
* Subclasses that need their own setup/teardown should add their own
* `@Before` / `@After` methods; JUnit runs all of them.
*
* Multi-relay tests use [hub] directly:
* ```
* val relayA = RelayUrlNormalizer.normalize("ws://relay-a/")
* hub.getOrCreate(relayA).preload(eventA)
* hub.getOrCreate(relayB).preload(eventB)
* ```
*/
open class RelayClientTest {
val hub: RelayHub = RelayHub()
val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client: NostrClient = NostrClient(hub, scope)
/** Stable URL for the single-relay case — see [RelayHub.DEFAULT_URL]. */
val defaultRelayUrl: NormalizedRelayUrl get() = RelayHub.DEFAULT_URL
/** Lazy handle to the relay at [defaultRelayUrl]. Auto-created on first read. */
val defaultRelay: Relay get() = hub.getOrCreate(defaultRelayUrl)
@After
fun tearDownRelayClientTest() {
client.disconnect()
scope.cancel()
hub.close()
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.testing
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.withTimeout
/** Tagged result of [collectUntilEose]: stored events plus whether EOSE actually arrived. */
data class CollectResult(
val events: List<Event>,
val eoseReceived: Boolean,
)
/**
* Subscribe with [filter] on a single [relay], drain the
* historical-replay phase, and return when EOSE arrives. The
* subscription is closed before this returns. The pattern that 80% of
* REQ-style tests need.
*
* ```
* val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1)))
* assertEquals(20, events.size)
* assertTrue(eose)
* ```
*
* @param timeoutMillis time to wait for EOSE before failing the test.
* Default 5 s — generous for in-process; tighten if needed.
*/
suspend fun NostrClient.collectUntilEose(
relay: NormalizedRelayUrl,
filter: Filter,
timeoutMillis: Long = 5_000,
): CollectResult = collectUntilEoseMulti(relay, listOf(filter), timeoutMillis)
/**
* Multi-filter variant. NIP-01 allows a REQ to carry several filters
* that the relay OR's together. EOSE fires once after the union of all
* filters has been replayed.
*/
suspend fun NostrClient.collectUntilEoseMulti(
relay: NormalizedRelayUrl,
filters: List<Filter>,
timeoutMillis: Long = 5_000,
): CollectResult {
val ch = Channel<Signal>(UNLIMITED)
val subId = "test-sub-${nextSubId()}"
subscribe(
subId,
mapOf(relay to filters),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Signal.Ev(event))
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Signal.Eose)
}
},
)
val events = mutableListOf<Event>()
var eose = false
try {
withTimeout(timeoutMillis) {
while (!eose) {
when (val msg = ch.receive()) {
is Signal.Ev -> events += msg.event
Signal.Eose -> eose = true
}
}
}
} finally {
unsubscribe(subId)
}
return CollectResult(events, eose)
}
private sealed interface Signal {
data class Ev(
val event: Event,
) : Signal
object Eose : Signal
}
/** Monotonic counter for unique sub-ids inside a JVM. */
private var subIdSeq: Int = 0
private fun nextSubId(): Int = ++subIdSeq
+13 -2
View File
@@ -183,8 +183,10 @@ kotlin {
// In-process Nostr relay (geode) so JVM/Android host // In-process Nostr relay (geode) so JVM/Android host
// tests don't need network access or a Rust toolchain. // tests don't need network access or a Rust toolchain.
// The `geode.fixtures` package carries the test-only // testFixtures (RelayClientTest base, fixtures,
// event generators and corpus loader. // collectUntilEose) are wired below at the top-level
// `dependencies` block — the KMP source-set DSL
// doesn't expose the `testFixtures(...)` consumer.
implementation(project(":geode")) implementation(project(":geode"))
} }
} }
@@ -347,6 +349,15 @@ kotlin {
} }
} }
// testFixtures(...) consumer lives outside the KMP source-set DSL —
// the KMP source-set `dependencies { }` block uses
// `KotlinDependencyHandler`, which does not expose the
// `testFixtures(...)` projection. The standard Gradle dependency
// configuration name (`jvmAndroidTestImplementation`) does work here.
dependencies {
"jvmAndroidTestImplementation"(testFixtures(project(":geode")))
}
mavenPublishing { mavenPublishing {
// sources publishing is always enabled by the Kotlin Multiplatform plugin // sources publishing is always enabled by the Kotlin Multiplatform plugin
configure( configure(
@@ -1,40 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.RelayHub
/**
* Base for tests that drive a real `NostrClient` against an in-process Nostr
* relay. Each subclass instance gets its own [RelayHub] so tests can
* preload events and assert deterministic counts without hitting the
* network or relying on production relays.
*
* To replace with the previous behaviour (real OkHttp WebSocket against
* `wss://nos.lol`), instantiate `BasicOkHttpWebSocket.Builder` directly in
* the specific test that needs it.
*/
open class BaseNostrClientTest {
val relayHub: RelayHub = RelayHub()
/** Plug into `NostrClient(socketBuilder, scope)`. */
val socketBuilder get() = relayHub
}
@@ -20,25 +20,20 @@
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientFirstEventTest : BaseNostrClientTest() { class NostrClientFirstEventTest : RelayClientTest() {
@Test @Test
fun testDownloadFirstEvent() = fun testDownloadFirstEvent() =
runBlocking { runBlocking {
val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val relayUrl = "ws://127.0.0.1:7770/"
val seed = val seed =
Event( Event(
@@ -50,25 +45,14 @@ class NostrClientFirstEventTest : BaseNostrClientTest() {
content = """{"name":"vitor"}""", content = """{"name":"vitor"}""",
sig = "b".repeat(128), sig = "b".repeat(128),
) )
relayHub.getOrCreate(relayUrl).preload(seed) defaultRelay.preload(seed)
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val event = val event =
client.fetchFirst( client.fetchFirst(
relay = relayUrl, relay = defaultRelayUrl,
filter = filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubKey)),
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(pubKey),
),
) )
client.disconnect()
appScope.cancel()
relayHub.close()
assertEquals(MetadataEvent.KIND, event?.kind) assertEquals(MetadataEvent.KIND, event?.kind)
assertEquals(pubKey, event?.pubKey) assertEquals(pubKey, event?.pubKey)
} }
@@ -19,18 +19,14 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -38,17 +34,11 @@ import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientManualSubTest : BaseNostrClientTest() { class NostrClientManualSubTest : RelayClientTest() {
@Test @Test
fun testEoseAfter100Events() = fun testEoseAfter100Events() =
runBlocking { runBlocking {
val relayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") defaultRelay.preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND))
relayHub
.getOrCreate(relayUrl)
.preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND))
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val resultChannel = Channel<String>(UNLIMITED) val resultChannel = Channel<String>(UNLIMITED)
val events = mutableListOf<String>() val events = mutableListOf<String>()
@@ -73,18 +63,11 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
} }
} }
val filters = client.subscribe(
mapOf( mySubId,
relayUrl to mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))),
listOf( listener,
Filter( )
kinds = listOf(MetadataEvent.KIND),
limit = 100,
),
),
)
client.subscribe(mySubId, filters, listener)
withTimeoutOrNull(10000) { withTimeoutOrNull(10000) {
while (events.size < 101) { while (events.size < 101) {
@@ -94,12 +77,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
} }
resultChannel.close() resultChannel.close()
client.unsubscribe(mySubId) client.unsubscribe(mySubId)
client.disconnect()
appScope.cancel()
relayHub.close()
assertEquals(101, events.size) assertEquals(101, events.size)
assertEquals(true, events.take(100).all { it.length == 64 }) assertEquals(true, events.take(100).all { it.length == 64 })
@@ -21,19 +21,15 @@
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientQueryCountTest : BaseNostrClientTest() { class NostrClientQueryCountTest : RelayClientTest() {
private val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl() private val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl()
private val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl() private val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl()
@@ -44,16 +40,16 @@ class NostrClientQueryCountTest : BaseNostrClientTest() {
// 5 metadata + 3 outbox relay events on A, 2 metadata + 7 outbox on B. // 5 metadata + 3 outbox relay events on A, 2 metadata + 7 outbox on B.
// Each event needs a distinct (kind, pubkey, dTag) to avoid replaceable-event collisions. // Each event needs a distinct (kind, pubkey, dTag) to avoid replaceable-event collisions.
fun pk(seed: Int) = SyntheticEvents.hexId(seed) fun pk(seed: Int) = SyntheticEvents.hexId(seed)
relayHub.getOrCreate(relayA).preload( hub.getOrCreate(relayA).preload(
(1..5).map { SyntheticEvents.fakeEvent(idSeed = it, kind = 0, pubKey = pk(it)) }, (1..5).map { SyntheticEvents.fakeEvent(idSeed = it, kind = 0, pubKey = pk(it)) },
) )
relayHub.getOrCreate(relayA).preload( hub.getOrCreate(relayA).preload(
(1..3).map { SyntheticEvents.fakeEvent(idSeed = 1000 + it, kind = 10002, pubKey = pk(1000 + it)) }, (1..3).map { SyntheticEvents.fakeEvent(idSeed = 1000 + it, kind = 10002, pubKey = pk(1000 + it)) },
) )
relayHub.getOrCreate(relayB).preload( hub.getOrCreate(relayB).preload(
(1..2).map { SyntheticEvents.fakeEvent(idSeed = 2000 + it, kind = 0, pubKey = pk(2000 + it)) }, (1..2).map { SyntheticEvents.fakeEvent(idSeed = 2000 + it, kind = 0, pubKey = pk(2000 + it)) },
) )
relayHub.getOrCreate(relayB).preload( hub.getOrCreate(relayB).preload(
(1..7).map { SyntheticEvents.fakeEvent(idSeed = 3000 + it, kind = 10002, pubKey = pk(3000 + it)) }, (1..7).map { SyntheticEvents.fakeEvent(idSeed = 3000 + it, kind = 10002, pubKey = pk(3000 + it)) },
) )
} }
@@ -62,41 +58,22 @@ class NostrClientQueryCountTest : BaseNostrClientTest() {
fun testQueryCountSuspend() = fun testQueryCountSuspend() =
runBlocking { runBlocking {
seed() seed()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val result = client.count(relayA, metadata) val result = client.count(relayA, metadata)
assertEquals(5, result?.count) assertEquals(5, result?.count)
client.disconnect()
appScope.cancel()
relayHub.close()
} }
@Test @Test
fun testQueryCountSuspendAllEvents() = fun testQueryCountSuspendAllEvents() =
runBlocking { runBlocking {
seed() seed()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val result = client.count(relayA, Filter()) val result = client.count(relayA, Filter())
assertEquals(8, result?.count) assertEquals(8, result?.count)
client.disconnect()
appScope.cancel()
relayHub.close()
} }
@Test @Test
fun testQueryCountSuspendMultipleRelays() = fun testQueryCountSuspendMultipleRelays() =
runBlocking { runBlocking {
seed() seed()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val results = val results =
client.count( client.count(
mapOf( mapOf(
@@ -107,9 +84,5 @@ class NostrClientQueryCountTest : BaseNostrClientTest() {
assertEquals(8, results[relayA]?.count) assertEquals(8, results[relayA]?.count)
assertEquals(9, results[relayB]?.count) assertEquals(9, results[relayB]?.count)
client.disconnect()
appScope.cancel()
relayHub.close()
} }
} }
@@ -19,22 +19,18 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@@ -44,12 +40,12 @@ import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientRepeatSubTest : BaseNostrClientTest() { class NostrClientRepeatSubTest : RelayClientTest() {
@Test @Test
fun testRepeatSubEvents() = fun testRepeatSubEvents() =
runBlocking { runBlocking {
// Each replaceable kind needs unique pubkeys. // Each replaceable kind needs unique pubkeys.
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( defaultRelay.preload(
(1..150).map { (1..150).map {
SyntheticEvents.fakeEvent( SyntheticEvents.fakeEvent(
idSeed = it, idSeed = it,
@@ -58,7 +54,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
) )
}, },
) )
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( defaultRelay.preload(
(1..50).map { (1..50).map {
SyntheticEvents.fakeEvent( SyntheticEvents.fakeEvent(
idSeed = 100_000 + it, idSeed = 100_000 + it,
@@ -68,9 +64,6 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
}, },
) )
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val resultChannel = Channel<String>(UNLIMITED) val resultChannel = Channel<String>(UNLIMITED)
val events = mutableListOf<String>() val events = mutableListOf<String>()
val mySubId = "test-sub-id-2" val mySubId = "test-sub-id-2"
@@ -101,38 +94,11 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
client.addConnectionListener(listener) client.addConnectionListener(listener)
val filters = val filters = mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100)))
mapOf(
RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 100,
),
),
)
val filtersShouldIgnore = val filtersShouldIgnore =
mapOf( mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 500)))
RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to
listOf(
Filter(
kinds = listOf(AdvertisedRelayListEvent.KIND),
limit = 500,
),
),
)
val filtersShouldSendAfterEOSE = val filtersShouldSendAfterEOSE =
mapOf( mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 10)))
RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to
listOf(
Filter(
kinds = listOf(AdvertisedRelayListEvent.KIND),
limit = 10,
),
),
)
coroutineScope { coroutineScope {
launch { launch {
@@ -161,30 +127,18 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
client.unsubscribe(mySubId) client.unsubscribe(mySubId)
client.removeConnectionListener(listener) client.removeConnectionListener(listener)
client.disconnect()
appScope.cancel()
relayHub.close()
// The relay may return up to limit events before EOSE; some relays return // The relay may return up to limit events before EOSE; some relays return
// one extra past the requested limit, so don't assert on the exact count. // one extra past the requested limit, so don't assert on the exact count.
// First sub: <= 100 metadata events, then EOSE.
// Second sub: <= 10 advertised relay list events, then EOSE.
val firstEose = events.indexOf("EOSE") val firstEose = events.indexOf("EOSE")
val lastEose = events.lastIndexOf("EOSE") val lastEose = events.lastIndexOf("EOSE")
// both EOSEs must be present and distinct
assertEquals(true, firstEose >= 0) assertEquals(true, firstEose >= 0)
assertEquals(true, lastEose > firstEose) assertEquals(true, lastEose > firstEose)
// last entry is the second EOSE (loop stops on it)
assertEquals(events.size - 1, lastEose) assertEquals(events.size - 1, lastEose)
// first sub stays within its limit (allow +1 for relay quirks)
assertEquals(true, firstEose in 1..101) assertEquals(true, firstEose in 1..101)
// second sub stays within its limit (allow +1 for relay quirks)
assertEquals(true, (lastEose - firstEose - 1) in 1..11) assertEquals(true, (lastEose - firstEose - 1) in 1..11)
// everything before the first EOSE is an event id
assertEquals(true, events.take(firstEose).all { it.length == 64 }) assertEquals(true, events.take(firstEose).all { it.length == 64 })
// everything between the two EOSEs is an event id
assertEquals(true, events.subList(firstEose + 1, lastEose).all { it.length == 64 }) assertEquals(true, events.subList(firstEose + 1, lastEose).all { it.length == 64 })
} }
} }
@@ -21,22 +21,17 @@
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { class NostrClientReqBypassingRelayLimitsTest : RelayClientTest() {
@Test @Test
fun testDownloadFromRelayReturnsMetadataEvents() = fun testDownloadFromRelayReturnsMetadataEvents() =
runBlocking { runBlocking {
@@ -50,32 +45,18 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() {
pubKey = SyntheticEvents.hexId(it), pubKey = SyntheticEvents.hexId(it),
) )
} }
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload(corpus) defaultRelay.preload(corpus)
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val events = mutableListOf<Event>() val events = mutableListOf<Event>()
val totalFound = val totalFound =
client.fetchAllPages( client.fetchAllPages(
relay = "ws://127.0.0.1:7770/", relay = defaultRelayUrl,
filters = filters = listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 1000)),
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 1000,
),
),
) { event -> ) { event ->
events.add(event) events.add(event)
} }
client.disconnect()
delay(500)
appScope.cancel()
relayHub.close()
assertEquals(1000, totalFound) assertEquals(1000, totalFound)
assertEquals(1000, events.size) assertEquals(1000, events.size)
events.forEach { event -> events.forEach { event ->
@@ -102,27 +83,18 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() {
pubKey = SyntheticEvents.hexId(100_000 + it), pubKey = SyntheticEvents.hexId(100_000 + it),
) )
} }
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload(metadata + contacts) defaultRelay.preload(metadata + contacts)
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val metadataEvents = mutableListOf<Event>() val metadataEvents = mutableListOf<Event>()
val contactListEvents = mutableListOf<Event>() val contactListEvents = mutableListOf<Event>()
val totalFound = val totalFound =
client.fetchAllPages( client.fetchAllPages(
relay = "ws://127.0.0.1:7770/", relay = defaultRelayUrl,
filters = filters =
listOf( listOf(
Filter( Filter(kinds = listOf(MetadataEvent.KIND), limit = 1000),
kinds = listOf(MetadataEvent.KIND), Filter(kinds = listOf(ContactListEvent.KIND), limit = 1500),
limit = 1000,
),
Filter(
kinds = listOf(ContactListEvent.KIND),
limit = 1500,
),
), ),
) { event -> ) { event ->
if (event.kind == MetadataEvent.KIND) { if (event.kind == MetadataEvent.KIND) {
@@ -133,11 +105,6 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() {
} }
} }
client.disconnect()
delay(500)
appScope.cancel()
relayHub.close()
assertEquals(2500, totalFound) assertEquals(2500, totalFound)
assertEquals(1000, metadataEvents.size) assertEquals(1000, metadataEvents.size)
assertEquals(1500, contactListEvents.size) assertEquals(1500, contactListEvents.size)
@@ -19,49 +19,29 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientSendAndWaitTest : BaseNostrClientTest() { class NostrClientSendAndWaitTest : RelayClientTest() {
@Test @Test
fun testSendAndWaitForResponse() = fun testSendAndWaitForResponse() =
runBlocking { runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val randomSigner = NostrSignerInternal(KeyPair()) val randomSigner = NostrSignerInternal(KeyPair())
val event = randomSigner.sign(TextNoteEvent.build("Hello World")) val event = randomSigner.sign(TextNoteEvent.build("Hello World"))
val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl() val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl()
val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl() val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl()
val resultA = val resultA = client.publishAndConfirm(event = event, relayList = setOf(relayA))
client.publishAndConfirm( val resultB = client.publishAndConfirm(event = event, relayList = setOf(relayB))
event = event,
relayList = setOf(relayA),
)
val resultB =
client.publishAndConfirm(
event = event,
relayList = setOf(relayB),
)
client.disconnect()
appScope.cancel()
relayHub.close()
assertEquals(true, resultA) assertEquals(true, resultA)
assertEquals(true, resultB) assertEquals(true, resultB)
@@ -19,19 +19,16 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
@@ -39,7 +36,7 @@ import kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { class NostrClientSubscriptionAsFlowTest : RelayClientTest() {
fun List<Event>.printDates(): String { fun List<Event>.printDates(): String {
val starting = this[0].createdAt val starting = this[0].createdAt
return joinToString { (it.createdAt - starting).toString() } return joinToString { (it.createdAt - starting).toString() }
@@ -49,20 +46,12 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
@Test @Test
fun testNostrClientSubscriptionAsFlow() = fun testNostrClientSubscriptionAsFlow() =
runTest { runTest {
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND))
SyntheticEvents.batch(20, kind = MetadataEvent.KIND),
)
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val flow = val flow =
client.subscribeAsFlow( client.subscribeAsFlow(
relay = "ws://127.0.0.1:7770/", relay = defaultRelayUrl,
filter = filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10),
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 10,
),
) )
var feedStates = listOf<Event>() var feedStates = listOf<Event>()
@@ -79,11 +68,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
advanceUntilIdle() advanceUntilIdle()
} }
job.cancel() // Cancel the collection job job.cancel()
client.disconnect()
appScope.cancel()
relayHub.close()
assertEquals(10, feedStates.size) assertEquals(10, feedStates.size)
} }
@@ -92,20 +77,12 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
@Test @Test
fun testNostrClientSubscriptionAsFlowDebouncing() = fun testNostrClientSubscriptionAsFlowDebouncing() =
runTest { runTest {
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND))
SyntheticEvents.batch(20, kind = MetadataEvent.KIND),
)
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val flow = val flow =
client.subscribeAsFlow( client.subscribeAsFlow(
relay = "ws://127.0.0.1:7770/", relay = defaultRelayUrl,
filter = filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10),
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 10,
),
) )
var feedStates = listOf<Event>() var feedStates = listOf<Event>()
@@ -117,16 +94,11 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
} }
} }
// Advance the test dispatcher to ensure emissions are processed
while (feedStates.size < 10) { while (feedStates.size < 10) {
advanceUntilIdle() advanceUntilIdle()
} }
job.cancel() // Cancel the collection job job.cancel()
client.disconnect()
appScope.cancel()
relayHub.close()
assertEquals(10, feedStates.size) assertEquals(10, feedStates.size)
} }
@@ -19,17 +19,13 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.StaticSubscription import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.StaticSubscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -37,15 +33,11 @@ import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientSubscriptionTest : BaseNostrClientTest() { class NostrClientSubscriptionTest : RelayClientTest() {
@Test @Test
fun testNostrClientSubscription() = fun testNostrClientSubscription() =
runBlocking { runBlocking {
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( defaultRelay.preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND))
SyntheticEvents.batch(150, kind = MetadataEvent.KIND),
)
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val resultChannel = Channel<Event>(UNLIMITED) val resultChannel = Channel<Event>(UNLIMITED)
val events = mutableSetOf<Event>() val events = mutableSetOf<Event>()
@@ -53,15 +45,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() {
val sub = val sub =
StaticSubscription( StaticSubscription(
client, client,
mapOf( mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))),
RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 100,
),
),
),
) { event -> ) { event ->
assertEquals(MetadataEvent.KIND, event.kind) assertEquals(MetadataEvent.KIND, event.kind)
resultChannel.trySend(event) resultChannel.trySend(event)
@@ -75,13 +59,8 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() {
} }
resultChannel.close() resultChannel.close()
sub.close() sub.close()
client.disconnect()
appScope.cancel()
relayHub.close()
assertEquals(100, events.size) assertEquals(100, events.size)
} }
} }
@@ -19,19 +19,16 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
@@ -39,7 +36,7 @@ import kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { class NostrClientSubscriptionUntilEoseAsFlowTest : RelayClientTest() {
fun List<Event>.printDates(): String { fun List<Event>.printDates(): String {
val starting = this[0].createdAt val starting = this[0].createdAt
return joinToString { (it.createdAt - starting).toString() } return joinToString { (it.createdAt - starting).toString() }
@@ -49,20 +46,12 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
@Test @Test
fun testNostrClientSubscriptionUntilEoseAsFlow() = fun testNostrClientSubscriptionUntilEoseAsFlow() =
runTest { runTest {
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND))
SyntheticEvents.batch(20, kind = MetadataEvent.KIND),
)
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val flow = val flow =
client.fetchAsFlow( client.fetchAsFlow(
relay = "ws://127.0.0.1:7770/", relay = defaultRelayUrl,
filter = filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10),
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 10,
),
) )
var feedStates = listOf<Event>() var feedStates = listOf<Event>()
@@ -74,16 +63,11 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
} }
} }
// Advance the test dispatcher to ensure emissions are processed
while (feedStates.size < 10) { while (feedStates.size < 10) {
advanceUntilIdle() advanceUntilIdle()
} }
job.cancel() // Cancel the collection job job.cancel()
client.disconnect()
appScope.cancel()
relayHub.close()
assertEquals(10, feedStates.size) assertEquals(10, feedStates.size)
} }
@@ -92,20 +76,12 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
@Test @Test
fun testNostrClientSubscriptionUntilEoseAsFlowDebouncing() = fun testNostrClientSubscriptionUntilEoseAsFlowDebouncing() =
runTest { runTest {
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND))
SyntheticEvents.batch(20, kind = MetadataEvent.KIND),
)
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val flow = val flow =
client.fetchAsFlow( client.fetchAsFlow(
relay = "ws://127.0.0.1:7770/", relay = defaultRelayUrl,
filter = filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10),
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 10,
),
) )
var feedStates = listOf<Event>() var feedStates = listOf<Event>()
@@ -117,16 +93,11 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
} }
} }
// Advance the test dispatcher to ensure emissions are processed
while (feedStates.size < 10) { while (feedStates.size < 10) {
advanceUntilIdle() advanceUntilIdle()
} }
job.cancel() // Cancel the collection job job.cancel()
client.disconnect()
appScope.cancel()
relayHub.close()
assertEquals(10, feedStates.size) assertEquals(10, feedStates.size)
} }