From c19bd4e92ed6d794ff7b10028ee17d2e61a33d89 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 13:42:20 +0000 Subject: [PATCH] refactor(geode): test fixtures + collectUntilEose helper, move to testFixtures sourceSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- geode/build.gradle.kts | 17 ++ .../com/vitorpamplona/geode/RelayHub.kt | 12 ++ .../geode/Nip01ComplianceTest.kt | 172 ++++-------------- .../geode/fixtures/RelayFixtures.kt | 0 .../geode/fixtures/SyntheticEvents.kt | 0 .../geode/testing/RelayClientTest.kt | 78 ++++++++ .../geode/testing/SubscriptionTesting.kt | 121 ++++++++++++ quartz/build.gradle.kts | 15 +- .../nip01Core/relay/BaseNostrClientTest.kt | 40 ---- .../relay/NostrClientFirstEventTest.kt | 26 +-- .../relay/NostrClientManualSubTest.kt | 40 +--- .../relay/NostrClientQueryCountTest.kt | 39 +--- .../relay/NostrClientRepeatSubTest.kt | 62 +------ .../NostrClientReqBypassingRelayLimitsTest.kt | 51 +----- .../relay/NostrClientSendAndWaitTest.kt | 30 +-- .../NostrClientSubscriptionAsFlowTest.kt | 50 ++--- .../relay/NostrClientSubscriptionTest.kt | 31 +--- ...trClientSubscriptionUntilEoseAsFlowTest.kt | 51 ++---- 18 files changed, 342 insertions(+), 493 deletions(-) rename geode/src/{main => testFixtures}/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt (100%) rename geode/src/{main => testFixtures}/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt (100%) create mode 100644 geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/RelayClientTest.kt create mode 100644 geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt delete mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt diff --git a/geode/build.gradle.kts b/geode/build.gradle.kts index 8d2f2dc89..3b05db1cf 100644 --- a/geode/build.gradle.kts +++ b/geode/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.serialization) application + `java-test-fixtures` } application { @@ -25,6 +26,15 @@ sourceSets { test { 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().configureEach { @@ -63,6 +73,13 @@ dependencies { // port their configs nearly verbatim. 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.kotlinx.coroutines.test) testImplementation(libs.secp256k1.kmp.jni.jvm) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt index a5cdccd00..6a6866306 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt @@ -86,4 +86,16 @@ class RelayHub( relays.values.forEach { runCatching { it.close() } } 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/") + } } diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt index 5f69577ea..7a5a865cc 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt @@ -21,26 +21,22 @@ package com.vitorpamplona.geode 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.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.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync 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.Factory.UNLIMITED import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull -import kotlin.test.AfterTest -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -62,29 +58,9 @@ import kotlin.test.assertTrue * spec-compliant relay (nostr-rs-relay, strfry, khatru, …) — only the * `socketBuilder` and the relay URL would change. */ -class Nip01ComplianceTest { - 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() - } - +class Nip01ComplianceTest : RelayClientTest() { private suspend fun preload(vararg events: Event) { - hub.getOrCreate(relayUrl).preload(*events) + defaultRelay.preload(*events) } private fun fakeEvent( @@ -108,7 +84,7 @@ class Nip01ComplianceTest { 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(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) @@ -126,7 +102,7 @@ class Nip01ComplianceTest { 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(400L, events[0].createdAt) @@ -145,7 +121,7 @@ class Nip01ComplianceTest { 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) assertTrue(events.all { it.pubKey == alice }) @@ -157,7 +133,7 @@ class Nip01ComplianceTest { runBlocking { 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(SyntheticEvents.hexId(2), events[0].id) @@ -174,7 +150,7 @@ class Nip01ComplianceTest { 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()) } @@ -190,7 +166,7 @@ class Nip01ComplianceTest { 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()) } @@ -206,7 +182,7 @@ class Nip01ComplianceTest { 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()) } @@ -221,7 +197,7 @@ class Nip01ComplianceTest { 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()) } @@ -241,7 +217,7 @@ class Nip01ComplianceTest { 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()) } @@ -262,7 +238,8 @@ class Nip01ComplianceTest { ) val (events, _) = - collectUntilEoseMulti( + client.collectUntilEoseMulti( + defaultRelayUrl, listOf( Filter(kinds = listOf(1)), Filter(kinds = listOf(7)), @@ -294,7 +271,7 @@ class Nip01ComplianceTest { client.subscribe( "sub-A", - mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))), object : SubscriptionListener { override fun onEvent( event: Event, @@ -315,7 +292,7 @@ class Nip01ComplianceTest { ) client.subscribe( "sub-B", - mapOf(relayUrl to listOf(Filter(kinds = listOf(4)))), + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(4)))), object : SubscriptionListener { override fun onEvent( event: Event, @@ -362,7 +339,7 @@ class Nip01ComplianceTest { 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("new", events[0].content) @@ -383,7 +360,11 @@ class Nip01ComplianceTest { val v3 = signer.sign(LongTextNoteEvent.build("list-b", "title", dTag = "list-b", createdAt = 100)) 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(setOf("new", "list-b"), events.map { it.content }.toSet()) @@ -399,7 +380,7 @@ class Nip01ComplianceTest { val gotEose = Channel(UNLIMITED) client.subscribe( "live-1", - mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))), object : SubscriptionListener { override fun onEvent( event: Event, @@ -423,7 +404,7 @@ class Nip01ComplianceTest { // Inject an event through the wire path (not preload — that bypasses // 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() } assertEquals("live", received.content) @@ -438,7 +419,7 @@ class Nip01ComplianceTest { val gotEose = Channel(UNLIMITED) client.subscribe( "live-2", - mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))), object : SubscriptionListener { override fun onEvent( event: Event, @@ -459,7 +440,7 @@ class Nip01ComplianceTest { ) 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() } assertNull(seen, "kind 4 should not match a kind-1 subscription") @@ -479,7 +460,7 @@ class Nip01ComplianceTest { val gotEose = Channel(UNLIMITED) client.subscribe( "eph-1", - mapOf(relayUrl to listOf(Filter(kinds = listOf(20_001)))), + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(20_001)))), object : SubscriptionListener { override fun onEvent( event: Event, @@ -500,7 +481,7 @@ class Nip01ComplianceTest { ) 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() } assertEquals("ephemeral-payload", received.content) @@ -516,10 +497,10 @@ class Nip01ComplianceTest { fun ephemeralEventIsNotStoredAndDoesNotShowOnFollowupReq() = runBlocking { // 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. - 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") assertEquals(0, events.size, "Ephemeral events must not be persisted") } @@ -570,93 +551,4 @@ class Nip01ComplianceTest { assertEquals("from-a", received[relayA]) assertEquals("from-b", received[relayB]) } - - // -- Helpers ------------------------------------------------------------ - - /** Subscribes synchronously and returns the events received before EOSE. */ - private suspend fun collectUntilEose(filter: Filter): Pair, Boolean> { - val ch = Channel(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?, - ) { - ch.trySend(Either.Ev(event)) - } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - ch.trySend(Either.Eose) - } - }, - ) - - val events = mutableListOf() - 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): Pair, Boolean> { - val ch = Channel(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?, - ) { - ch.trySend(Either.Ev(event)) - } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - ch.trySend(Either.Eose) - } - }, - ) - val events = mutableListOf() - 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 - } } diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt similarity index 100% rename from geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt rename to geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt similarity index 100% rename from geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt rename to geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt diff --git a/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/RelayClientTest.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/RelayClientTest.kt new file mode 100644 index 000000000..76112f2b3 --- /dev/null +++ b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/RelayClientTest.kt @@ -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() + } +} diff --git a/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt new file mode 100644 index 000000000..d2c7ec2d0 --- /dev/null +++ b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt @@ -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, + 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, + timeoutMillis: Long = 5_000, +): CollectResult { + val ch = Channel(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?, + ) { + ch.trySend(Signal.Ev(event)) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Signal.Eose) + } + }, + ) + + val events = mutableListOf() + 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 diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 93df4636b..f1b7f6721 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -183,8 +183,10 @@ kotlin { // In-process Nostr relay (geode) so JVM/Android host // tests don't need network access or a Rust toolchain. - // The `geode.fixtures` package carries the test-only - // event generators and corpus loader. + // testFixtures (RelayClientTest base, fixtures, + // collectUntilEose) are wired below at the top-level + // `dependencies` block — the KMP source-set DSL + // doesn't expose the `testFixtures(...)` consumer. 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 { // sources publishing is always enabled by the Kotlin Multiplatform plugin configure( diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt deleted file mode 100644 index 242539bda..000000000 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt +++ /dev/null @@ -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 -} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt index d5fcae43d..5149c7ad1 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt @@ -20,25 +20,20 @@ */ package com.vitorpamplona.quartz.nip01Core.relay +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.core.Event 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.filters.Filter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientFirstEventTest : BaseNostrClientTest() { +class NostrClientFirstEventTest : RelayClientTest() { @Test fun testDownloadFirstEvent() = runBlocking { val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" - val relayUrl = "ws://127.0.0.1:7770/" val seed = Event( @@ -50,25 +45,14 @@ class NostrClientFirstEventTest : BaseNostrClientTest() { content = """{"name":"vitor"}""", sig = "b".repeat(128), ) - relayHub.getOrCreate(relayUrl).preload(seed) - - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(seed) val event = client.fetchFirst( - relay = relayUrl, - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - authors = listOf(pubKey), - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubKey)), ) - client.disconnect() - appScope.cancel() - relayHub.close() - assertEquals(MetadataEvent.KIND, event?.kind) assertEquals(pubKey, event?.pubKey) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index 58fc091e8..8a0aad1d6 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -19,18 +19,14 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.core.Event 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.filters.Filter 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.Factory.UNLIMITED import kotlinx.coroutines.runBlocking @@ -38,17 +34,11 @@ import kotlinx.coroutines.withTimeoutOrNull import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientManualSubTest : BaseNostrClientTest() { +class NostrClientManualSubTest : RelayClientTest() { @Test fun testEoseAfter100Events() = runBlocking { - val relayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") - relayHub - .getOrCreate(relayUrl) - .preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND)) - - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND)) val resultChannel = Channel(UNLIMITED) val events = mutableListOf() @@ -73,18 +63,11 @@ class NostrClientManualSubTest : BaseNostrClientTest() { } } - val filters = - mapOf( - relayUrl to - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 100, - ), - ), - ) - - client.subscribe(mySubId, filters, listener) + client.subscribe( + mySubId, + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))), + listener, + ) withTimeoutOrNull(10000) { while (events.size < 101) { @@ -94,12 +77,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() { } resultChannel.close() - client.unsubscribe(mySubId) - client.disconnect() - - appScope.cancel() - relayHub.close() assertEquals(101, events.size) assertEquals(true, events.take(100).all { it.length == 64 }) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt index 0459238ed..6d0a1248a 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt @@ -21,19 +21,15 @@ package com.vitorpamplona.quartz.nip01Core.relay 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.filters.Filter 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 kotlin.test.Test import kotlin.test.assertEquals -class NostrClientQueryCountTest : BaseNostrClientTest() { +class NostrClientQueryCountTest : RelayClientTest() { private val relayA = "ws://127.0.0.1:7771/".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. // Each event needs a distinct (kind, pubkey, dTag) to avoid replaceable-event collisions. 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)) }, ) - relayHub.getOrCreate(relayA).preload( + hub.getOrCreate(relayA).preload( (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)) }, ) - relayHub.getOrCreate(relayB).preload( + hub.getOrCreate(relayB).preload( (1..7).map { SyntheticEvents.fakeEvent(idSeed = 3000 + it, kind = 10002, pubKey = pk(3000 + it)) }, ) } @@ -62,41 +58,22 @@ class NostrClientQueryCountTest : BaseNostrClientTest() { fun testQueryCountSuspend() = runBlocking { seed() - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) - val result = client.count(relayA, metadata) - assertEquals(5, result?.count) - - client.disconnect() - appScope.cancel() - relayHub.close() } @Test fun testQueryCountSuspendAllEvents() = runBlocking { seed() - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) - val result = client.count(relayA, Filter()) - assertEquals(8, result?.count) - - client.disconnect() - appScope.cancel() - relayHub.close() } @Test fun testQueryCountSuspendMultipleRelays() = runBlocking { seed() - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) - val results = client.count( mapOf( @@ -107,9 +84,5 @@ class NostrClientQueryCountTest : BaseNostrClientTest() { assertEquals(8, results[relayA]?.count) assertEquals(9, results[relayB]?.count) - - client.disconnect() - appScope.cancel() - relayHub.close() } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index 20d25ec24..21876a67d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -19,22 +19,18 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest 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.single.IRelayClient 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.Message 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.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.Factory.UNLIMITED import kotlinx.coroutines.coroutineScope @@ -44,12 +40,12 @@ import kotlinx.coroutines.withTimeoutOrNull import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientRepeatSubTest : BaseNostrClientTest() { +class NostrClientRepeatSubTest : RelayClientTest() { @Test fun testRepeatSubEvents() = runBlocking { // Each replaceable kind needs unique pubkeys. - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( + defaultRelay.preload( (1..150).map { SyntheticEvents.fakeEvent( idSeed = it, @@ -58,7 +54,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { ) }, ) - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( + defaultRelay.preload( (1..50).map { SyntheticEvents.fakeEvent( 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(UNLIMITED) val events = mutableListOf() val mySubId = "test-sub-id-2" @@ -101,38 +94,11 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { client.addConnectionListener(listener) - val filters = - mapOf( - RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 100, - ), - ), - ) - + val filters = mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))) val filtersShouldIgnore = - mapOf( - RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to - listOf( - Filter( - kinds = listOf(AdvertisedRelayListEvent.KIND), - limit = 500, - ), - ), - ) - + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 500))) val filtersShouldSendAfterEOSE = - mapOf( - RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to - listOf( - Filter( - kinds = listOf(AdvertisedRelayListEvent.KIND), - limit = 10, - ), - ), - ) + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 10))) coroutineScope { launch { @@ -161,30 +127,18 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { client.unsubscribe(mySubId) client.removeConnectionListener(listener) - client.disconnect() - - appScope.cancel() - relayHub.close() // 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. - // First sub: <= 100 metadata events, then EOSE. - // Second sub: <= 10 advertised relay list events, then EOSE. val firstEose = events.indexOf("EOSE") val lastEose = events.lastIndexOf("EOSE") - // both EOSEs must be present and distinct assertEquals(true, firstEose >= 0) assertEquals(true, lastEose > firstEose) - // last entry is the second EOSE (loop stops on it) assertEquals(events.size - 1, lastEose) - // first sub stays within its limit (allow +1 for relay quirks) 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) - // everything before the first EOSE is an event id 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 }) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt index f6383421c..9b961b7ff 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt @@ -21,22 +21,17 @@ package com.vitorpamplona.quartz.nip01Core.relay import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.core.Event 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.filters.Filter 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 kotlin.test.Test import kotlin.test.assertEquals -class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { +class NostrClientReqBypassingRelayLimitsTest : RelayClientTest() { @Test fun testDownloadFromRelayReturnsMetadataEvents() = runBlocking { @@ -50,32 +45,18 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { pubKey = SyntheticEvents.hexId(it), ) } - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload(corpus) - - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(corpus) val events = mutableListOf() val totalFound = client.fetchAllPages( - relay = "ws://127.0.0.1:7770/", - filters = - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 1000, - ), - ), + relay = defaultRelayUrl, + filters = listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 1000)), ) { event -> events.add(event) } - client.disconnect() - delay(500) - appScope.cancel() - relayHub.close() - assertEquals(1000, totalFound) assertEquals(1000, events.size) events.forEach { event -> @@ -102,27 +83,18 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { pubKey = SyntheticEvents.hexId(100_000 + it), ) } - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload(metadata + contacts) - - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(metadata + contacts) val metadataEvents = mutableListOf() val contactListEvents = mutableListOf() val totalFound = client.fetchAllPages( - relay = "ws://127.0.0.1:7770/", + relay = defaultRelayUrl, filters = listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 1000, - ), - Filter( - kinds = listOf(ContactListEvent.KIND), - limit = 1500, - ), + Filter(kinds = listOf(MetadataEvent.KIND), limit = 1000), + Filter(kinds = listOf(ContactListEvent.KIND), limit = 1500), ), ) { event -> if (event.kind == MetadataEvent.KIND) { @@ -133,11 +105,6 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { } } - client.disconnect() - delay(500) - appScope.cancel() - relayHub.close() - assertEquals(2500, totalFound) assertEquals(1000, metadataEvents.size) assertEquals(1500, contactListEvents.size) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt index ae28b862e..fc9acdc6e 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt @@ -19,49 +19,29 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.geode.testing.RelayClientTest 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.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal 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 kotlin.test.Test import kotlin.test.assertEquals -class NostrClientSendAndWaitTest : BaseNostrClientTest() { +class NostrClientSendAndWaitTest : RelayClientTest() { @Test fun testSendAndWaitForResponse() = runBlocking { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) - val randomSigner = NostrSignerInternal(KeyPair()) - val event = randomSigner.sign(TextNoteEvent.build("Hello World")) val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl() val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl() - val resultA = - client.publishAndConfirm( - event = event, - relayList = setOf(relayA), - ) - - val resultB = - client.publishAndConfirm( - event = event, - relayList = setOf(relayB), - ) - - client.disconnect() - appScope.cancel() - relayHub.close() + val resultA = client.publishAndConfirm(event = event, relayList = setOf(relayA)) + val resultB = client.publishAndConfirm(event = event, relayList = setOf(relayB)) assertEquals(true, resultA) assertEquals(true, resultB) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt index 99e243d8c..b749a7d90 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt @@ -19,19 +19,16 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.core.Event 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.filters.Filter import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle @@ -39,7 +36,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { +class NostrClientSubscriptionAsFlowTest : RelayClientTest() { fun List.printDates(): String { val starting = this[0].createdAt return joinToString { (it.createdAt - starting).toString() } @@ -49,20 +46,12 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionAsFlow() = runTest { - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( - SyntheticEvents.batch(20, kind = MetadataEvent.KIND), - ) - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND)) val flow = client.subscribeAsFlow( - relay = "ws://127.0.0.1:7770/", - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 10, - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10), ) var feedStates = listOf() @@ -79,11 +68,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { advanceUntilIdle() } - job.cancel() // Cancel the collection job - - client.disconnect() - appScope.cancel() - relayHub.close() + job.cancel() assertEquals(10, feedStates.size) } @@ -92,20 +77,12 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionAsFlowDebouncing() = runTest { - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( - SyntheticEvents.batch(20, kind = MetadataEvent.KIND), - ) - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND)) val flow = client.subscribeAsFlow( - relay = "ws://127.0.0.1:7770/", - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 10, - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10), ) var feedStates = listOf() @@ -117,16 +94,11 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { } } - // Advance the test dispatcher to ensure emissions are processed while (feedStates.size < 10) { advanceUntilIdle() } - job.cancel() // Cancel the collection job - - client.disconnect() - appScope.cancel() - relayHub.close() + job.cancel() assertEquals(10, feedStates.size) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt index a4dad911d..9ec9bfc54 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt @@ -19,17 +19,13 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.core.Event 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.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.Factory.UNLIMITED import kotlinx.coroutines.runBlocking @@ -37,15 +33,11 @@ import kotlinx.coroutines.withTimeoutOrNull import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientSubscriptionTest : BaseNostrClientTest() { +class NostrClientSubscriptionTest : RelayClientTest() { @Test fun testNostrClientSubscription() = runBlocking { - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( - SyntheticEvents.batch(150, kind = MetadataEvent.KIND), - ) - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND)) val resultChannel = Channel(UNLIMITED) val events = mutableSetOf() @@ -53,15 +45,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { val sub = StaticSubscription( client, - mapOf( - RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 100, - ), - ), - ), + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))), ) { event -> assertEquals(MetadataEvent.KIND, event.kind) resultChannel.trySend(event) @@ -75,13 +59,8 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { } resultChannel.close() - sub.close() - client.disconnect() - appScope.cancel() - relayHub.close() - assertEquals(100, events.size) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt index 7ab1e1e27..9f8ce103c 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt @@ -19,19 +19,16 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.core.Event 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.filters.Filter import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle @@ -39,7 +36,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { +class NostrClientSubscriptionUntilEoseAsFlowTest : RelayClientTest() { fun List.printDates(): String { val starting = this[0].createdAt return joinToString { (it.createdAt - starting).toString() } @@ -49,20 +46,12 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionUntilEoseAsFlow() = runTest { - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( - SyntheticEvents.batch(20, kind = MetadataEvent.KIND), - ) - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND)) val flow = client.fetchAsFlow( - relay = "ws://127.0.0.1:7770/", - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 10, - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10), ) var feedStates = listOf() @@ -74,16 +63,11 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { } } - // Advance the test dispatcher to ensure emissions are processed while (feedStates.size < 10) { advanceUntilIdle() } - job.cancel() // Cancel the collection job - - client.disconnect() - appScope.cancel() - relayHub.close() + job.cancel() assertEquals(10, feedStates.size) } @@ -92,20 +76,12 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionUntilEoseAsFlowDebouncing() = runTest { - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( - SyntheticEvents.batch(20, kind = MetadataEvent.KIND), - ) - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND)) val flow = client.fetchAsFlow( - relay = "ws://127.0.0.1:7770/", - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 10, - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10), ) var feedStates = listOf() @@ -117,16 +93,11 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { } } - // Advance the test dispatcher to ensure emissions are processed while (feedStates.size < 10) { advanceUntilIdle() } - job.cancel() // Cancel the collection job - - client.disconnect() - appScope.cancel() - relayHub.close() + job.cancel() assertEquals(10, feedStates.size) }