From aefecf71d88c813fcc04fe9a7377e90a9dfe1643 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 00:41:02 +0000 Subject: [PATCH] test(quartz): add :quartz-test-relay for in-process Nostr relay testing Replaces production-relay (wss://nos.lol, wss://nostr.bitcoiner.social) dependencies in 10 quartz JVM relay tests with a deterministic in-process Nostr relay built on top of the existing NostrServer + EventStore, plus a NIP-01 compliance suite that exercises the bridge end-to-end through NostrClient. The new :quartz-test-relay module exposes: - TestRelay / TestRelayHub: NostrServer + in-memory EventStore per URL, registry implements WebsocketBuilder so tests can drop it into NostrClient in place of BasicOkHttpWebSocket.Builder. - InProcessWebSocket: bridges the WebSocket abstraction to RelaySession with a single-coroutine drain to preserve message ordering. - SyntheticEvents / RelayFixtures: deterministic event generators and a loader for the existing nostr_vitor_*.json corpora. Found and fixed three NIP-01/NIP-45 wire-format bugs in the relay serializers that prevented round-trip through the in-tree NostrClient: - OkMessage wrote success as a JSON string instead of a boolean, causing publishAndConfirm to hang. - CountMessage dropped the queryId, breaking COUNT response routing. - CountResult used the field name "pubkey" instead of "approximate". Both Jackson and kotlinx-serialization paths were affected; both fixed to match NIP-01/NIP-45 wire formats. --- quartz-test-relay/build.gradle.kts | 35 ++ .../quartz/testrelay/InProcessWebSocket.kt | 79 ++++ .../quartz/testrelay/RelayFixtures.kt | 71 +++ .../quartz/testrelay/SyntheticEvents.kt | 66 +++ .../quartz/testrelay/TestRelay.kt | 79 ++++ .../quartz/testrelay/TestRelayHub.kt | 76 ++++ .../quartz/testrelay/Nip01ComplianceTest.kt | 409 ++++++++++++++++++ quartz/build.gradle.kts | 4 + .../CountResultKSerializer.kt | 6 +- .../kotlinSerialization/MessageKSerializer.kt | 10 +- .../relay/server/NostrServerAuthTest.kt | 24 +- .../nip01Core/relay/server/NostrServerTest.kt | 6 +- .../toClient/CountResultSerializer.kt | 5 +- .../commands/toClient/MessageSerializer.kt | 10 +- .../nip01Core/relay/BaseNostrClientTest.kt | 44 +- .../relay/NostrClientFirstEventTest.kt | 24 +- .../relay/NostrClientManualSubTest.kt | 9 +- .../relay/NostrClientQueryCountTest.kt | 54 ++- .../relay/NostrClientRepeatSubTest.kt | 28 +- .../NostrClientReqBypassingRelayLimitsTest.kt | 58 ++- .../relay/NostrClientSendAndWaitTest.kt | 16 +- .../NostrClientSubscriptionAsFlowTest.kt | 13 +- .../relay/NostrClientSubscriptionTest.kt | 7 +- ...trClientSubscriptionUntilEoseAsFlowTest.kt | 13 +- settings.gradle | 1 + 25 files changed, 1041 insertions(+), 106 deletions(-) create mode 100644 quartz-test-relay/build.gradle.kts create mode 100644 quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt create mode 100644 quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt create mode 100644 quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt create mode 100644 quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt create mode 100644 quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt create mode 100644 quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt diff --git a/quartz-test-relay/build.gradle.kts b/quartz-test-relay/build.gradle.kts new file mode 100644 index 000000000..3c87e3f99 --- /dev/null +++ b/quartz-test-relay/build.gradle.kts @@ -0,0 +1,35 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.jetbrainsKotlinJvm) +} + +kotlin { + jvmToolchain(21) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } +} + +sourceSets { + main { + kotlin.srcDir("src/main/kotlin") + } + test { + kotlin.srcDir("src/test/kotlin") + } +} + +dependencies { + api(project(":quartz")) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.jackson.module.kotlin) + + // Bundled SQLite driver — EventStore(null) creates an in-memory DB at runtime. + implementation(libs.androidx.sqlite.bundled.jvm) + + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.secp256k1.kmp.jni.jvm) +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt new file mode 100644 index 000000000..b487ce46f --- /dev/null +++ b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt @@ -0,0 +1,79 @@ +/* + * 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.testrelay + +import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +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.launch + +/** + * In-memory implementation of [WebSocket] that talks to a [TestRelay] without + * touching the network. Each instance opens one [RelaySession] on + * [connect] and routes: + * + * - Outbound (`send`) → server `RelaySession.receive()` via an inbound channel + * drained by a single coroutine, preserving message order per the + * [WebSocketListener] contract. + * - Server-side `send` callbacks → [WebSocketListener.onMessage]. + */ +class InProcessWebSocket( + private val relay: TestRelay, + private val out: WebSocketListener, +) : WebSocket { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val incoming = Channel(UNLIMITED) + private var session: RelaySession? = null + + override fun needsReconnect(): Boolean = session == null + + override fun connect() { + if (session != null) return + val s = relay.server.connect { json -> out.onMessage(json) } + session = s + out.onOpen(0, false) + scope.launch { + for (msg in incoming) { + s.receive(msg) + } + } + } + + override fun disconnect() { + val s = session ?: return + session = null + incoming.close() + scope.cancel() + s.close() + out.onClosed(1000, "client disconnect") + } + + override fun send(msg: String): Boolean { + if (session == null) return false + return incoming.trySend(msg).isSuccess + } +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt new file mode 100644 index 000000000..6fc510385 --- /dev/null +++ b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt @@ -0,0 +1,71 @@ +/* + * 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.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import java.util.zip.GZIPInputStream + +/** + * Loaders for test event corpora bundled in `quartz/src/commonTest/resources/`. + * Lookup order: + * 1. The `TEST_RESOURCES_ROOT` environment variable (set by quartz's Gradle + * config to the absolute path of `commonTest/resources`). + * 2. The classpath, for callers that copy fixtures into their own + * `src/test/resources`. + */ +object RelayFixtures { + /** Reads a fixture file as a UTF-8 string. */ + fun loadString(name: String): String { + val envRoot = System.getenv("TEST_RESOURCES_ROOT") + if (envRoot != null) { + val file = java.io.File(envRoot, name) + if (file.exists()) return file.readText() + } + val cp = RelayFixtures::class.java.classLoader?.getResourceAsStream(name) + if (cp != null) return cp.bufferedReader().use { it.readText() } + throw IllegalArgumentException( + "Fixture not found: $name. Set TEST_RESOURCES_ROOT or place on classpath.", + ) + } + + /** Reads a gzipped fixture file as a UTF-8 string. */ + fun loadGzipString(name: String): String { + val envRoot = System.getenv("TEST_RESOURCES_ROOT") + if (envRoot != null) { + val file = java.io.File(envRoot, name) + if (file.exists()) { + return GZIPInputStream(file.inputStream()).bufferedReader().use { it.readText() } + } + } + val cp = RelayFixtures::class.java.classLoader?.getResourceAsStream(name) + if (cp != null) return GZIPInputStream(cp).bufferedReader().use { it.readText() } + throw IllegalArgumentException( + "Fixture not found: $name. Set TEST_RESOURCES_ROOT or place on classpath.", + ) + } + + /** Loads `nostr_vitor_short.json` — the small handcrafted Vitor corpus. */ + fun vitorShort(): List = OptimizedJsonMapper.fromJsonToEventList(loadString("nostr_vitor_short.json")) + + /** Loads `nostr_vitor_startup_data.json.gz` — the larger Vitor startup corpus. */ + fun vitorStartup(): List = OptimizedJsonMapper.fromJsonToEventList(loadGzipString("nostr_vitor_startup_data.json")) +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt new file mode 100644 index 000000000..640dfa4b1 --- /dev/null +++ b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt @@ -0,0 +1,66 @@ +/* + * 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.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event + +/** + * Generators for cheap, deterministic, structurally valid Nostr events. + * + * The signatures and ids produced here are *not* cryptographically valid — + * the in-process relay's default [com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy] + * doesn't verify them, and neither does the underlying SQLite event store. + * Use these when a test only needs to exercise relay logic (filter matching, + * limits, EOSE, live updates) and not the cryptographic layer. + */ +object SyntheticEvents { + /** A pubkey/sig pair that's syntactically valid (64/128 hex chars) but signs nothing. */ + private val DEFAULT_PUBKEY = "0".repeat(64) + private val FAKE_SIG = "0".repeat(128) + + /** Hex padding to 64 chars so deterministic ids look like real event ids. */ + fun hexId(seed: Int): String = seed.toString().padStart(64, '0') + + fun fakeEvent( + idSeed: Int, + kind: Int = 1, + pubKey: String = DEFAULT_PUBKEY, + createdAt: Long = idSeed.toLong(), + content: String = "", + tags: Array> = emptyArray(), + ): Event = Event(hexId(idSeed), pubKey, createdAt, kind, tags, content, FAKE_SIG) + + /** + * Returns [count] events of [kind] with monotonic [createdAt] starting at 1 + * and a *distinct* pubkey per event. Distinct pubkeys are essential for + * replaceable kinds (0, 3, 10000-19999): without them, the relay collapses + * the whole batch to one row per (kind, pubkey). + */ + fun batch( + count: Int, + kind: Int = 1, + pubKeyOf: (Int) -> String = { hexId(1_000_000 + it) }, + ): List = + List(count) { i -> + val seed = i + 1 + fakeEvent(idSeed = seed, kind = kind, pubKey = pubKeyOf(seed), createdAt = seed.toLong()) + } +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt new file mode 100644 index 000000000..ccd251457 --- /dev/null +++ b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt @@ -0,0 +1,79 @@ +/* + * 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.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.CoroutineContext + +/** + * A self-contained, in-memory Nostr relay scoped to a single URL. Wraps a + * [NostrServer] over an [EventStore] backed by an in-memory SQLite database. + * + * Use [TestRelayHub] to register relays under URLs the production + * [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] can subscribe to. + */ +class TestRelay( + val url: NormalizedRelayUrl, + val store: IEventStore = EventStore(dbName = null, relay = url), + policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, + parentContext: CoroutineContext = SupervisorJob(), +) : AutoCloseable { + val server = NostrServer(store, policyBuilder, parentContext) + + /** + * Inserts events directly into the underlying store, bypassing the wire protocol. + * + * Use this for **pre-test setup** — events that exist before any client connects. + * It does NOT broadcast to active subscriptions. For sending events that should + * fan out to live subscribers (post-EOSE), use [publish] instead. + */ + suspend fun preload(events: Iterable) { + events.forEach { store.insert(it) } + } + + /** @see preload(Iterable) */ + suspend fun preload(vararg events: Event) = preload(events.toList()) + + /** + * Publishes an event through the relay's session machinery so it both lands + * in the store and fans out to active subscriptions matching its filters + * (mirrors what a real client would do via an `EVENT` command). + */ + suspend fun publish(event: Event) { + val session = server.connect { /* ignore OK echo */ } + try { + session.receive(OptimizedJsonMapper.toJson(EventCmd(event))) + } finally { + session.close() + } + } + + override fun close() = server.close() +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt new file mode 100644 index 000000000..45a3fab10 --- /dev/null +++ b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt @@ -0,0 +1,76 @@ +/* + * 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.testrelay + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import java.util.concurrent.ConcurrentHashMap + +/** + * Registry of [TestRelay] instances keyed by relay URL. Implements + * [WebsocketBuilder] so it can be plugged into + * [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] in place of + * `BasicOkHttpWebSocket.Builder` to redirect every outbound connection to an + * in-memory relay. + * + * Usage: + * ``` + * val hub = TestRelayHub() + * val relay = hub.getOrCreate("ws://test.relay/") + * runBlocking { relay.preload(listOf(event1, event2)) } + * val client = NostrClient(hub, scope) + * ``` + * + * Unknown URLs auto-create an empty relay so a single hub can transparently + * back any number of test endpoints. + */ +class TestRelayHub( + private val defaultPolicy: () -> IRelayPolicy = { EmptyPolicy }, +) : WebsocketBuilder, + AutoCloseable { + private val relays = ConcurrentHashMap() + + fun getOrCreate(url: NormalizedRelayUrl): TestRelay = + relays.getOrPut(url) { + TestRelay(url = url, policyBuilder = defaultPolicy) + } + + fun getOrCreate(url: String): TestRelay = getOrCreate(RelayUrlNormalizer.normalize(url)) + + fun get(url: NormalizedRelayUrl): TestRelay? = relays[url] + + fun urls(): Set = relays.keys.toSet() + + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ): WebSocket = InProcessWebSocket(getOrCreate(url), out) + + override fun close() { + relays.values.forEach { it.close() } + relays.clear() + } +} diff --git a/quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt b/quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt new file mode 100644 index 000000000..2fc3487f6 --- /dev/null +++ b/quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt @@ -0,0 +1,409 @@ +/* + * 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.testrelay + +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 +import kotlin.test.assertTrue + +/** + * Compatibility suite that drives the in-process relay through the same + * `NostrClient` + WebSocket abstraction production code uses. These tests + * are how we validate that: + * + * 1. The relay implements NIP-01 correctly (REQ/EVENT/EOSE/CLOSE/COUNT, + * replaceable + parameterized-replaceable, filter matching, multi-relay + * pools). + * 2. The bridge between [com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket] + * and [com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer] preserves + * wire ordering and lifecycle. + * + * The same suite should be runnable, conceptually, against any + * spec-compliant relay (nostr-rs-relay, strfry, khatru, …) — only the + * `socketBuilder` and the relay URL would change. + */ +class Nip01ComplianceTest { + private lateinit var hub: TestRelayHub + 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 = TestRelayHub() + 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) { + hub.getOrCreate(relayUrl).preload(*events) + } + + private fun fakeEvent( + idSeed: Int, + kind: Int = 1, + pubKey: String = SyntheticEvents.hexId(0), + createdAt: Long = idSeed.toLong(), + tags: Array> = emptyArray(), + content: String = "", + ) = SyntheticEvents.fakeEvent(idSeed, kind, pubKey, createdAt, content, tags) + + // -- Subscriptions ------------------------------------------------------- + + /** REQ returns events matching kinds, then EOSE, in order. */ + @Test + fun reqByKindReturnsMatchesThenEose() = + runBlocking { + preload( + fakeEvent(1, kind = 1), + fakeEvent(2, kind = 4), + fakeEvent(3, kind = 1), + ) + + val (events, eose) = collectUntilEose(Filter(kinds = listOf(1))) + + assertEquals(2, events.size) + assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) + assertTrue(eose, "EOSE should fire after stored events") + } + + /** REQ honours `limit` — newest first, capped to limit. */ + @Test + fun reqRespectsLimitAndOrdersNewestFirst() = + runBlocking { + preload( + fakeEvent(1, kind = 1, createdAt = 100), + fakeEvent(2, kind = 1, createdAt = 200), + fakeEvent(3, kind = 1, createdAt = 300), + fakeEvent(4, kind = 1, createdAt = 400), + ) + + val (events, _) = collectUntilEose(Filter(kinds = listOf(1), limit = 2)) + + assertEquals(2, events.size) + assertEquals(400L, events[0].createdAt) + assertEquals(300L, events[1].createdAt) + } + + /** REQ with `authors` filters by pubkey. */ + @Test + fun reqFiltersByAuthors() = + runBlocking { + val alice = SyntheticEvents.hexId(101) + val bob = SyntheticEvents.hexId(102) + preload( + fakeEvent(1, kind = 1, pubKey = alice), + fakeEvent(2, kind = 1, pubKey = bob), + fakeEvent(3, kind = 1, pubKey = alice), + ) + + val (events, _) = collectUntilEose(Filter(authors = listOf(alice))) + + assertEquals(2, events.size) + assertTrue(events.all { it.pubKey == alice }) + } + + /** REQ with `ids` returns only the requested events. */ + @Test + fun reqFiltersByIds() = + runBlocking { + preload(fakeEvent(1), fakeEvent(2), fakeEvent(3)) + + val (events, _) = collectUntilEose(Filter(ids = listOf(SyntheticEvents.hexId(2)))) + + assertEquals(1, events.size) + assertEquals(SyntheticEvents.hexId(2), events[0].id) + } + + /** REQ with `since`/`until` filters on createdAt. */ + @Test + fun reqFiltersBySinceAndUntil() = + runBlocking { + preload( + fakeEvent(1, createdAt = 100), + fakeEvent(2, createdAt = 200), + fakeEvent(3, createdAt = 300), + fakeEvent(4, createdAt = 400), + ) + + val (events, _) = collectUntilEose(Filter(since = 150L, until = 350L)) + + assertEquals(setOf(200L, 300L), events.map { it.createdAt }.toSet()) + } + + /** REQ with single-letter `#e` tag filter matches events whose tag values intersect. */ + @Test + fun reqFiltersByETag() = + runBlocking { + val target = SyntheticEvents.hexId(999) + preload( + fakeEvent(1, tags = arrayOf(arrayOf("e", target))), + fakeEvent(2, tags = arrayOf(arrayOf("e", SyntheticEvents.hexId(7)))), + fakeEvent(3, tags = arrayOf(arrayOf("e", target), arrayOf("p", SyntheticEvents.hexId(8)))), + ) + + val (events, _) = collectUntilEose(Filter(tags = mapOf("e" to listOf(target)))) + + assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) + } + + // -- Replaceable + addressable ------------------------------------------ + + /** Kind 0 is replaceable by `(pubkey, kind)` — newer wins. */ + @Test + fun replaceableEventsKeepNewestPerPubkey() = + runBlocking { + val pubkey = SyntheticEvents.hexId(50) + preload( + fakeEvent(1, kind = 0, pubKey = pubkey, createdAt = 100, content = "old"), + fakeEvent(2, kind = 0, pubKey = pubkey, createdAt = 200, content = "new"), + ) + + val (events, _) = collectUntilEose(Filter(kinds = listOf(0), authors = listOf(pubkey))) + + assertEquals(1, events.size) + assertEquals("new", events[0].content) + } + + /** + * Addressable events (kind 30000-39999, NIP-01 §"Kinds") are replaced + * by `(pubkey, kind, d)`. Uses a real signed [LongTextNoteEvent] because + * the SQLite store dispatches on the typed `AddressableEvent` subclass + * to extract the d-tag — synthetic plain `Event`s aren't recognised. + */ + @Test + fun parameterizedReplaceableEventsKeepNewestPerDTag() = + runBlocking { + val signer = NostrSignerSync(KeyPair()) + val v1 = signer.sign(LongTextNoteEvent.build("old", "title", dTag = "list-a", createdAt = 100)) + val v2 = signer.sign(LongTextNoteEvent.build("new", "title", dTag = "list-a", createdAt = 200)) + 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))) + + assertEquals(2, events.size) + assertEquals(setOf("new", "list-b"), events.map { it.content }.toSet()) + } + + // -- Live updates -------------------------------------------------------- + + /** A subscription receives new matching events that arrive after EOSE. */ + @Test + fun liveSubscriptionReceivesPostEoseEvents() = + runBlocking { + val ch = Channel(UNLIMITED) + val gotEose = Channel(UNLIMITED) + client.subscribe( + "live-1", + mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + gotEose.trySend(Unit) + } + }, + ) + + withTimeout(5000) { gotEose.receive() } + + // 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")) + + val received = withTimeout(5000) { ch.receive() } + assertEquals("live", received.content) + client.unsubscribe("live-1") + } + + /** Non-matching live events are not pushed to a subscription. */ + @Test + fun liveSubscriptionIgnoresNonMatchingEvents() = + runBlocking { + val ch = Channel(UNLIMITED) + val gotEose = Channel(UNLIMITED) + client.subscribe( + "live-2", + mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + gotEose.trySend(Unit) + } + }, + ) + withTimeout(5000) { gotEose.receive() } + + hub.getOrCreate(relayUrl).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") + client.unsubscribe("live-2") + } + + // -- Multi-relay -------------------------------------------------------- + + /** A single client can hold subscriptions against multiple relays simultaneously. */ + @Test + fun multiRelayPoolReturnsContentFromEachRelay() = + runBlocking { + val relayA = RelayUrlNormalizer.normalize("ws://127.0.0.1:7771/") + val relayB = RelayUrlNormalizer.normalize("ws://127.0.0.1:7772/") + hub.getOrCreate(relayA).preload(fakeEvent(1, kind = 1, content = "from-a")) + hub.getOrCreate(relayB).preload(fakeEvent(2, kind = 1, content = "from-b")) + + val received = mutableMapOf() + val eosed = mutableSetOf() + val ch = Channel(UNLIMITED) + client.subscribe( + "multi-1", + mapOf( + relayA to listOf(Filter(kinds = listOf(1))), + relayB to listOf(Filter(kinds = listOf(1))), + ), + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + received[relay] = event.content + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eosed += relay + if (eosed.size == 2) ch.trySend(Unit) + } + }, + ) + + withTimeout(5000) { ch.receive() } + client.unsubscribe("multi-1") + + 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 + } + + private sealed interface Either { + data class Ev( + val event: Event, + ) : Either + + object Eose : Either + } +} diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index b591f62f7..a6a6fc9a9 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -180,6 +180,10 @@ kotlin { dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) + + // In-process Nostr relay so JVM/Android host tests don't + // need network access or a Rust toolchain. + implementation(project(":quartz-test-relay")) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt index 27cc372c3..9cb90e4d8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt @@ -42,7 +42,7 @@ object CountResultKSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("CountResult") { element("count") - element("pubkey") + element("approximate") } override fun serialize( @@ -56,8 +56,8 @@ object CountResultKSerializer : KSerializer { fun serializeToElement(value: CountResult): JsonObject = buildJsonObject { put("count", value.count) - // Matches Jackson's CountResultSerializer which writes "pubkey" for approximate - put("pubkey", value.approximate) + // NIP-45: include "approximate" only when true. + if (value.approximate) put("approximate", true) value.hll?.let { put("hll", HyperLogLog.encode(it)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 4edf4ee4f..800420e49 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -68,12 +68,10 @@ object MessageKSerializer : KSerializer { } is OkMessage -> { + // NIP-01 wire format: ["OK", , , ] add(JsonPrimitive(value.eventId)) - // Jackson writes success as a string, not boolean - add(JsonPrimitive(value.success.toString())) - if (value.message.isNotBlank()) { - add(JsonPrimitive(value.message)) - } + add(JsonPrimitive(value.success)) + add(JsonPrimitive(value.message)) } is AuthMessage -> { @@ -90,6 +88,8 @@ object MessageKSerializer : KSerializer { } is CountMessage -> { + // NIP-45 wire format: ["COUNT", , ] + add(JsonPrimitive(value.queryId)) add(CountResultKSerializer.serializeToElement(value.result)) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt index 4e7200643..76d2ddd78 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt @@ -163,7 +163,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) assertTrue((session.policy as FullAuthPolicy).isAuthenticated()) assertTrue(session.policy.authenticatedUsers.contains(pubkey)) @@ -184,7 +184,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains(",false,")) assertTrue(okMessages[0].contains("challenge")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) @@ -209,7 +209,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains(",false,")) assertTrue(okMessages[0].contains("relay url")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) @@ -238,7 +238,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains(",false,")) assertTrue(okMessages[0].contains("created_at")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) @@ -307,8 +307,8 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(2, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) - assertTrue(okMessages[1].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) + assertTrue(okMessages[1].contains(",true,")) val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers assertEquals(2, authedPubkeys.size) @@ -334,7 +334,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains(",false,")) assertTrue(okMessages[0].contains("auth-required:")) server.close() @@ -394,7 +394,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) // Now EVENT should work val event = testEvent() @@ -402,7 +402,7 @@ class NostrServerAuthTest { val allOk = collector.rawMessagesContaining("OK") assertEquals(2, allOk.size) - assertTrue(allOk[1].contains("\"true\"")) + assertTrue(allOk[1].contains(",true,")) // REQ should work session.receive("""["REQ","sub1",{"kinds":[1]}]""") @@ -428,7 +428,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) server.close() } @@ -461,14 +461,14 @@ class NostrServerAuthTest { // Kind 1 should be accepted without auth val note = testEvent(hexId(1), kind = 1) session.receive("""["EVENT",${note.toJson()}]""") - assertTrue(collector.rawMessagesContaining("OK")[0].contains("\"true\"")) + assertTrue(collector.rawMessagesContaining("OK")[0].contains(",true,")) // Kind 4 should be rejected without auth val dm = testEvent(hexId(2), kind = 4) session.receive("""["EVENT",${dm.toJson()}]""") val okMessages = collector.rawMessagesContaining("OK") assertEquals(2, okMessages.size) - assertTrue(okMessages[1].contains("\"false\"")) + assertTrue(okMessages[1].contains(",false,")) assertTrue(okMessages[1].contains("auth-required:")) server.close() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt index 3e033cf49..3217b994a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt @@ -109,7 +109,7 @@ class NostrServerTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) // Event should be in store val stored = store.query(Filter(ids = listOf(event.id))) @@ -135,8 +135,8 @@ class NostrServerTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(2, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) - assertTrue(okMessages[1].contains("\"false\"")) + assertTrue(okMessages[0].contains(",true,")) + assertTrue(okMessages[1].contains(",false,")) server.close() } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt index efc63b229..131c30d71 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt @@ -30,9 +30,12 @@ class CountResultSerializer : StdSerializer(CountResult::class.java gen: JsonGenerator, provider: SerializerProvider, ) { + // NIP-45 result object: { "count": , "approximate": ? }. gen.writeStartObject() gen.writeNumberField("count", result.count) - gen.writeBooleanField("pubkey", result.approximate) + if (result.approximate) { + gen.writeBooleanField("approximate", true) + } gen.writeEndObject() } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index b0ca6557a..9048fe774 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -50,11 +50,11 @@ class MessageSerializer : StdSerializer(Message::class.java) { } is OkMessage -> { + // NIP-01 wire format: ["OK", , , ] + // The third element is a JSON boolean, not a string. gen.writeString(msg.eventId) - gen.writeString(msg.success.toString()) - if (msg.message.isNotBlank()) { - gen.writeString(msg.message) - } + gen.writeBoolean(msg.success) + gen.writeString(msg.message) } is AuthMessage -> { @@ -71,6 +71,8 @@ class MessageSerializer : StdSerializer(Message::class.java) { } is CountMessage -> { + // NIP-45 wire format: ["COUNT", , ] + gen.writeString(msg.queryId) countSerializer.serialize(msg.result, gen, provider) } 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 index 1431b7af5..6c1d0cbfa 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt @@ -20,35 +20,21 @@ */ package com.vitorpamplona.quartz.nip01Core.relay -import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response - -class DefaultContentTypeInterceptor( - private val userAgentHeader: String, -) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest: Request = chain.request() - val requestWithUserAgent: Request = - originalRequest - .newBuilder() - .header("User-Agent", userAgentHeader) - .build() - return chain.proceed(requestWithUserAgent) - } -} +import com.vitorpamplona.quartz.testrelay.TestRelayHub +/** + * Base for tests that drive a real `NostrClient` against an in-process Nostr + * relay. Each subclass instance gets its own [TestRelayHub] 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 { - companion object { - val rootClient = - OkHttpClient - .Builder() - .followRedirects(true) - .followSslRedirects(true) - .addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05")) - .build() - val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient } - } + val relayHub: TestRelayHub = TestRelayHub() + + /** 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 033ead5d1..d5fcae43d 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 @@ -19,6 +19,8 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + +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 @@ -35,23 +37,39 @@ class NostrClientFirstEventTest : BaseNostrClientTest() { @Test fun testDownloadFirstEvent() = runBlocking { + val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + val relayUrl = "ws://127.0.0.1:7770/" + + val seed = + Event( + id = "a".repeat(64), + pubKey = pubKey, + createdAt = 1000L, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = """{"name":"vitor"}""", + sig = "b".repeat(128), + ) + relayHub.getOrCreate(relayUrl).preload(seed) + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) val event = client.fetchFirst( - relay = "wss://nos.lol", + relay = relayUrl, filter = Filter( kinds = listOf(MetadataEvent.KIND), - authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + authors = listOf(pubKey), ), ) client.disconnect() appScope.cancel() + relayHub.close() assertEquals(MetadataEvent.KIND, event?.kind) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", event?.pubKey) + 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 d7412a0b3..ad5ac6123 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 @@ -26,6 +26,7 @@ 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.testrelay.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -41,6 +42,11 @@ class NostrClientManualSubTest : BaseNostrClientTest() { @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) @@ -69,7 +75,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() { val filters = mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to + relayUrl to listOf( Filter( kinds = listOf(MetadataEvent.KIND), @@ -93,6 +99,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() { 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 5851bcb0f..d7f9c236b 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 @@ -19,73 +19,97 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient 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 junit.framework.TestCase.assertTrue +import com.vitorpamplona.quartz.testrelay.SyntheticEvents 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() { - val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl() - val utxo = "wss://news.utxo.one".normalizeRelayUrl() + private val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl() + private val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl() - val metadata = Filter(kinds = listOf(0)) - val outboxRelays = Filter(kinds = listOf(10002)) + private val metadata = Filter(kinds = listOf(0)) + private val outboxRelays = Filter(kinds = listOf(10002)) + + private suspend fun seed() { + // 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( + (1..5).map { SyntheticEvents.fakeEvent(idSeed = it, kind = 0, pubKey = pk(it)) }, + ) + relayHub.getOrCreate(relayA).preload( + (1..3).map { SyntheticEvents.fakeEvent(idSeed = 1000 + it, kind = 10002, pubKey = pk(1000 + it)) }, + ) + relayHub.getOrCreate(relayB).preload( + (1..2).map { SyntheticEvents.fakeEvent(idSeed = 2000 + it, kind = 0, pubKey = pk(2000 + it)) }, + ) + relayHub.getOrCreate(relayB).preload( + (1..7).map { SyntheticEvents.fakeEvent(idSeed = 3000 + it, kind = 10002, pubKey = pk(3000 + it)) }, + ) + } @Test fun testQueryCountSuspend() = runBlocking { + seed() val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) - val result = client.count(fiatjaf, metadata) + val result = client.count(relayA, metadata) - assertTrue((result?.count ?: 0) > 1) + 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(fiatjaf, Filter()) + val result = client.count(relayA, Filter()) - assertTrue((result?.count ?: 0) > 1) + 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( - fiatjaf to listOf(metadata, outboxRelays), - utxo to listOf(metadata, outboxRelays), + relayA to listOf(metadata, outboxRelays), + relayB to listOf(metadata, outboxRelays), ), ) - results.forEach { (url, countResult) -> - println("${url.url}: ${countResult.count}") - assertTrue(countResult.count > 1) - } + 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 ea8da446b..25f1b93e5 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 @@ -29,6 +29,7 @@ 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.testrelay.SyntheticEvents import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -47,6 +48,26 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { @Test fun testRepeatSubEvents() = runBlocking { + // Each replaceable kind needs unique pubkeys. + relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( + (1..150).map { + SyntheticEvents.fakeEvent( + idSeed = it, + kind = MetadataEvent.KIND, + pubKey = SyntheticEvents.hexId(it), + ) + }, + ) + relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( + (1..50).map { + SyntheticEvents.fakeEvent( + idSeed = 100_000 + it, + kind = AdvertisedRelayListEvent.KIND, + pubKey = SyntheticEvents.hexId(100_000 + it), + ) + }, + ) + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) @@ -82,7 +103,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filters = mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to + RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to listOf( Filter( kinds = listOf(MetadataEvent.KIND), @@ -93,7 +114,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filtersShouldIgnore = mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to + RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to listOf( Filter( kinds = listOf(AdvertisedRelayListEvent.KIND), @@ -104,7 +125,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filtersShouldSendAfterEOSE = mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to + RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to listOf( Filter( kinds = listOf(AdvertisedRelayListEvent.KIND), @@ -143,6 +164,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { 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. 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 6ffb0de81..4e3c639c2 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 @@ -19,12 +19,14 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + 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 com.vitorpamplona.quartz.testrelay.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -38,15 +40,26 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { @Test fun testDownloadFromRelayReturnsMetadataEvents() = runBlocking { + // Each event needs a unique pubkey so replaceable kind 0 doesn't + // collapse them all to one row. + val corpus = + (1..1000).map { + SyntheticEvents.fakeEvent( + idSeed = it, + kind = MetadataEvent.KIND, + 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) val events = mutableListOf() - // nos.lol returns only 500 events per req val totalFound = client.fetchAllPages( - relay = "wss://nos.lol", + relay = "ws://127.0.0.1:7770/", filters = listOf( Filter( @@ -61,27 +74,45 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { client.disconnect() delay(500) appScope.cancel() + relayHub.close() - assertEquals(1000, totalFound, "Expected 1000 events from wss://nos.lol") - assertEquals(1000, events.size, "Events list should be 1000 events") + assertEquals(1000, totalFound) + assertEquals(1000, events.size) events.forEach { event -> - assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}") + assertEquals(MetadataEvent.KIND, event.kind) } } @Test fun testDownloadFromRelayReturnsMetadataAndContactListEvents() = runBlocking { + val metadata = + (1..1000).map { + SyntheticEvents.fakeEvent( + idSeed = it, + kind = MetadataEvent.KIND, + pubKey = SyntheticEvents.hexId(it), + ) + } + val contacts = + (1..1500).map { + SyntheticEvents.fakeEvent( + idSeed = 100_000 + it, + kind = ContactListEvent.KIND, + 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) val metadataEvents = mutableListOf() val contactListEvents = mutableListOf() - // nos.lol returns only 500 events per req val totalFound = client.fetchAllPages( - relay = "wss://nos.lol", + relay = "ws://127.0.0.1:7770/", filters = listOf( Filter( @@ -105,15 +136,10 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { client.disconnect() delay(500) appScope.cancel() + relayHub.close() - assertEquals(2500, totalFound, "Expected 1000 events from wss://nos.lol") - assertEquals(1000, metadataEvents.size, "Events list should be 1000 events") - assertEquals(1500, contactListEvents.size, "Events list should be 1000 events") - metadataEvents.forEach { event -> - assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}") - } - contactListEvents.forEach { event -> - assertEquals(ContactListEvent.KIND, event.kind, "All events should be kind ${ContactListEvent.KIND}") - } + 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 b9a17d219..ae28b862e 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 @@ -44,22 +44,26 @@ class NostrClientSendAndWaitTest : BaseNostrClientTest() { val event = randomSigner.sign(TextNoteEvent.build("Hello World")) - val resultDamus = + 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("wss://nostr.bitcoiner.social".normalizeRelayUrl()), + relayList = setOf(relayA), ) - val resultNos = + val resultB = client.publishAndConfirm( event = event, - relayList = setOf("wss://nos.lol".normalizeRelayUrl()), + relayList = setOf(relayB), ) client.disconnect() appScope.cancel() + relayHub.close() - assertEquals(true, resultDamus) - assertEquals(true, resultNos) + 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 3bf5e6699..30e670ff7 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 @@ -24,6 +24,7 @@ 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.testrelay.SyntheticEvents import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -48,12 +49,15 @@ 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) val flow = client.subscribeAsFlow( - relay = "wss://nos.lol", + relay = "ws://127.0.0.1:7770/", filter = Filter( kinds = listOf(MetadataEvent.KIND), @@ -79,6 +83,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { client.disconnect() appScope.cancel() + relayHub.close() assertEquals(10, feedStates.size) } @@ -87,12 +92,15 @@ 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) val flow = client.subscribeAsFlow( - relay = "wss://nos.lol", + relay = "ws://127.0.0.1:7770/", filter = Filter( kinds = listOf(MetadataEvent.KIND), @@ -118,6 +126,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { client.disconnect() appScope.cancel() + relayHub.close() 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 74e3a0215..141bf6cdf 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 @@ -25,6 +25,7 @@ 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 com.vitorpamplona.quartz.testrelay.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -40,6 +41,9 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { @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) @@ -50,7 +54,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { StaticSubscription( client, mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to + RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to listOf( Filter( kinds = listOf(MetadataEvent.KIND), @@ -76,6 +80,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { 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 c8054a6f7..e3de606f2 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 @@ -24,6 +24,7 @@ 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.testrelay.SyntheticEvents import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -48,12 +49,15 @@ 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) val flow = client.fetchAsFlow( - relay = "wss://nos.lol", + relay = "ws://127.0.0.1:7770/", filter = Filter( kinds = listOf(MetadataEvent.KIND), @@ -79,6 +83,7 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { client.disconnect() appScope.cancel() + relayHub.close() assertEquals(10, feedStates.size) } @@ -87,12 +92,15 @@ 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) val flow = client.fetchAsFlow( - relay = "wss://nos.lol", + relay = "ws://127.0.0.1:7770/", filter = Filter( kinds = listOf(MetadataEvent.KIND), @@ -118,6 +126,7 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { client.disconnect() appScope.cancel() + relayHub.close() assertEquals(10, feedStates.size) } diff --git a/settings.gradle b/settings.gradle index 38a632ba1..7ca462562 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,6 +34,7 @@ rootProject.name = "Amethyst" include ':amethyst' include ':benchmark' include ':quartz' +include ':quartz-test-relay' include ':commons' include ':ammolite' include ':quic'