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.
This commit is contained in:
Claude
2026-05-07 00:41:02 +00:00
parent 4538812d26
commit aefecf71d8
25 changed files with 1041 additions and 106 deletions
+35
View File
@@ -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)
}
@@ -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<String>(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
}
}
@@ -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<Event> = OptimizedJsonMapper.fromJsonToEventList(loadString("nostr_vitor_short.json"))
/** Loads `nostr_vitor_startup_data.json.gz` — the larger Vitor startup corpus. */
fun vitorStartup(): List<Event> = OptimizedJsonMapper.fromJsonToEventList(loadGzipString("nostr_vitor_startup_data.json"))
}
@@ -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<Array<String>> = 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<Event> =
List(count) { i ->
val seed = i + 1
fakeEvent(idSeed = seed, kind = kind, pubKey = pubKeyOf(seed), createdAt = seed.toLong())
}
}
@@ -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<Event>) {
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()
}
@@ -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<NormalizedRelayUrl, TestRelay>()
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<NormalizedRelayUrl> = 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()
}
}
@@ -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<Array<String>> = 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<Event>(UNLIMITED)
val gotEose = Channel<Unit>(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<Filter>?,
) {
ch.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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<Event>(UNLIMITED)
val gotEose = Channel<Unit>(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<Filter>?,
) {
ch.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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<NormalizedRelayUrl, String>()
val eosed = mutableSetOf<NormalizedRelayUrl>()
val ch = Channel<Unit>(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<Filter>?,
) {
received[relay] = event.content
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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<List<Event>, Boolean> {
val ch = Channel<Either>(UNLIMITED)
val subId = "sub-${System.nanoTime()}"
client.subscribe(
subId,
mapOf(relayUrl to listOf(filter)),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Either.Ev(event))
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Either.Eose)
}
},
)
val events = mutableListOf<Event>()
var eose = false
withTimeout(5000) {
while (!eose) {
when (val msg = ch.receive()) {
is Either.Ev -> events += msg.event
Either.Eose -> eose = true
}
}
}
client.unsubscribe(subId)
return events to eose
}
private sealed interface Either {
data class Ev(
val event: Event,
) : Either
object Eose : Either
}
}
+4
View File
@@ -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"))
}
}
@@ -42,7 +42,7 @@ object CountResultKSerializer : KSerializer<CountResult> {
override val descriptor: SerialDescriptor =
buildClassSerialDescriptor("CountResult") {
element<Int>("count")
element<Boolean>("pubkey")
element<Boolean>("approximate")
}
override fun serialize(
@@ -56,8 +56,8 @@ object CountResultKSerializer : KSerializer<CountResult> {
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)) }
}
@@ -68,12 +68,10 @@ object MessageKSerializer : KSerializer<Message> {
}
is OkMessage -> {
// NIP-01 wire format: ["OK", <event_id>, <true|false>, <message>]
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<Message> {
}
is CountMessage -> {
// NIP-45 wire format: ["COUNT", <query_id>, <count_result>]
add(JsonPrimitive(value.queryId))
add(CountResultKSerializer.serializeToElement(value.result))
}
@@ -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()
@@ -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<Event>(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()
}
@@ -30,9 +30,12 @@ class CountResultSerializer : StdSerializer<CountResult>(CountResult::class.java
gen: JsonGenerator,
provider: SerializerProvider,
) {
// NIP-45 result object: { "count": <int>, "approximate": <bool>? }.
gen.writeStartObject()
gen.writeNumberField("count", result.count)
gen.writeBooleanField("pubkey", result.approximate)
if (result.approximate) {
gen.writeBooleanField("approximate", true)
}
gen.writeEndObject()
}
}
@@ -50,11 +50,11 @@ class MessageSerializer : StdSerializer<Message>(Message::class.java) {
}
is OkMessage -> {
// NIP-01 wire format: ["OK", <event_id>, <true|false>, <message>]
// 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>(Message::class.java) {
}
is CountMessage -> {
// NIP-45 wire format: ["COUNT", <query_id>, <count_result>]
gen.writeString(msg.queryId)
countSerializer.serialize(msg.result, gen, provider)
}
@@ -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
}
@@ -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)
}
}
@@ -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 })
@@ -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()
}
}
@@ -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.
@@ -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<Event>()
// 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<Event>()
val contactListEvents = mutableListOf<Event>()
// 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)
}
}
@@ -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)
}
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
+1
View File
@@ -34,6 +34,7 @@ rootProject.name = "Amethyst"
include ':amethyst'
include ':benchmark'
include ':quartz'
include ':quartz-test-relay'
include ':commons'
include ':ammolite'
include ':quic'