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 { dependencies {
implementation(libs.kotlin.test) implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.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 = override val descriptor: SerialDescriptor =
buildClassSerialDescriptor("CountResult") { buildClassSerialDescriptor("CountResult") {
element<Int>("count") element<Int>("count")
element<Boolean>("pubkey") element<Boolean>("approximate")
} }
override fun serialize( override fun serialize(
@@ -56,8 +56,8 @@ object CountResultKSerializer : KSerializer<CountResult> {
fun serializeToElement(value: CountResult): JsonObject = fun serializeToElement(value: CountResult): JsonObject =
buildJsonObject { buildJsonObject {
put("count", value.count) put("count", value.count)
// Matches Jackson's CountResultSerializer which writes "pubkey" for approximate // NIP-45: include "approximate" only when true.
put("pubkey", value.approximate) if (value.approximate) put("approximate", true)
value.hll?.let { put("hll", HyperLogLog.encode(it)) } value.hll?.let { put("hll", HyperLogLog.encode(it)) }
} }
@@ -68,12 +68,10 @@ object MessageKSerializer : KSerializer<Message> {
} }
is OkMessage -> { is OkMessage -> {
// NIP-01 wire format: ["OK", <event_id>, <true|false>, <message>]
add(JsonPrimitive(value.eventId)) add(JsonPrimitive(value.eventId))
// Jackson writes success as a string, not boolean add(JsonPrimitive(value.success))
add(JsonPrimitive(value.success.toString())) add(JsonPrimitive(value.message))
if (value.message.isNotBlank()) {
add(JsonPrimitive(value.message))
}
} }
is AuthMessage -> { is AuthMessage -> {
@@ -90,6 +88,8 @@ object MessageKSerializer : KSerializer<Message> {
} }
is CountMessage -> { is CountMessage -> {
// NIP-45 wire format: ["COUNT", <query_id>, <count_result>]
add(JsonPrimitive(value.queryId))
add(CountResultKSerializer.serializeToElement(value.result)) add(CountResultKSerializer.serializeToElement(value.result))
} }
@@ -163,7 +163,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[0].contains(",true,"))
assertTrue((session.policy as FullAuthPolicy).isAuthenticated()) assertTrue((session.policy as FullAuthPolicy).isAuthenticated())
assertTrue(session.policy.authenticatedUsers.contains(pubkey)) assertTrue(session.policy.authenticatedUsers.contains(pubkey))
@@ -184,7 +184,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"false\"")) assertTrue(okMessages[0].contains(",false,"))
assertTrue(okMessages[0].contains("challenge")) assertTrue(okMessages[0].contains("challenge"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
@@ -209,7 +209,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"false\"")) assertTrue(okMessages[0].contains(",false,"))
assertTrue(okMessages[0].contains("relay url")) assertTrue(okMessages[0].contains("relay url"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
@@ -238,7 +238,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"false\"")) assertTrue(okMessages[0].contains(",false,"))
assertTrue(okMessages[0].contains("created_at")) assertTrue(okMessages[0].contains("created_at"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
@@ -307,8 +307,8 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(2, okMessages.size) assertEquals(2, okMessages.size)
assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[0].contains(",true,"))
assertTrue(okMessages[1].contains("\"true\"")) assertTrue(okMessages[1].contains(",true,"))
val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers
assertEquals(2, authedPubkeys.size) assertEquals(2, authedPubkeys.size)
@@ -334,7 +334,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"false\"")) assertTrue(okMessages[0].contains(",false,"))
assertTrue(okMessages[0].contains("auth-required:")) assertTrue(okMessages[0].contains("auth-required:"))
server.close() server.close()
@@ -394,7 +394,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[0].contains(",true,"))
// Now EVENT should work // Now EVENT should work
val event = testEvent() val event = testEvent()
@@ -402,7 +402,7 @@ class NostrServerAuthTest {
val allOk = collector.rawMessagesContaining("OK") val allOk = collector.rawMessagesContaining("OK")
assertEquals(2, allOk.size) assertEquals(2, allOk.size)
assertTrue(allOk[1].contains("\"true\"")) assertTrue(allOk[1].contains(",true,"))
// REQ should work // REQ should work
session.receive("""["REQ","sub1",{"kinds":[1]}]""") session.receive("""["REQ","sub1",{"kinds":[1]}]""")
@@ -428,7 +428,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[0].contains(",true,"))
server.close() server.close()
} }
@@ -461,14 +461,14 @@ class NostrServerAuthTest {
// Kind 1 should be accepted without auth // Kind 1 should be accepted without auth
val note = testEvent(hexId(1), kind = 1) val note = testEvent(hexId(1), kind = 1)
session.receive("""["EVENT",${note.toJson()}]""") 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 // Kind 4 should be rejected without auth
val dm = testEvent(hexId(2), kind = 4) val dm = testEvent(hexId(2), kind = 4)
session.receive("""["EVENT",${dm.toJson()}]""") session.receive("""["EVENT",${dm.toJson()}]""")
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(2, okMessages.size) assertEquals(2, okMessages.size)
assertTrue(okMessages[1].contains("\"false\"")) assertTrue(okMessages[1].contains(",false,"))
assertTrue(okMessages[1].contains("auth-required:")) assertTrue(okMessages[1].contains("auth-required:"))
server.close() server.close()
@@ -109,7 +109,7 @@ class NostrServerTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[0].contains(",true,"))
// Event should be in store // Event should be in store
val stored = store.query<Event>(Filter(ids = listOf(event.id))) val stored = store.query<Event>(Filter(ids = listOf(event.id)))
@@ -135,8 +135,8 @@ class NostrServerTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(2, okMessages.size) assertEquals(2, okMessages.size)
assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[0].contains(",true,"))
assertTrue(okMessages[1].contains("\"false\"")) assertTrue(okMessages[1].contains(",false,"))
server.close() server.close()
} }
@@ -30,9 +30,12 @@ class CountResultSerializer : StdSerializer<CountResult>(CountResult::class.java
gen: JsonGenerator, gen: JsonGenerator,
provider: SerializerProvider, provider: SerializerProvider,
) { ) {
// NIP-45 result object: { "count": <int>, "approximate": <bool>? }.
gen.writeStartObject() gen.writeStartObject()
gen.writeNumberField("count", result.count) gen.writeNumberField("count", result.count)
gen.writeBooleanField("pubkey", result.approximate) if (result.approximate) {
gen.writeBooleanField("approximate", true)
}
gen.writeEndObject() gen.writeEndObject()
} }
} }
@@ -50,11 +50,11 @@ class MessageSerializer : StdSerializer<Message>(Message::class.java) {
} }
is OkMessage -> { 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.eventId)
gen.writeString(msg.success.toString()) gen.writeBoolean(msg.success)
if (msg.message.isNotBlank()) { gen.writeString(msg.message)
gen.writeString(msg.message)
}
} }
is AuthMessage -> { is AuthMessage -> {
@@ -71,6 +71,8 @@ class MessageSerializer : StdSerializer<Message>(Message::class.java) {
} }
is CountMessage -> { is CountMessage -> {
// NIP-45 wire format: ["COUNT", <query_id>, <count_result>]
gen.writeString(msg.queryId)
countSerializer.serialize(msg.result, gen, provider) countSerializer.serialize(msg.result, gen, provider)
} }
@@ -20,35 +20,21 @@
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket import com.vitorpamplona.quartz.testrelay.TestRelayHub
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)
}
}
/**
* 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 { open class BaseNostrClientTest {
companion object { val relayHub: TestRelayHub = TestRelayHub()
val rootClient =
OkHttpClient /** Plug into `NostrClient(socketBuilder, scope)`. */
.Builder() val socketBuilder get() = relayHub
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
}
} }
@@ -19,6 +19,8 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
@@ -35,23 +37,39 @@ class NostrClientFirstEventTest : BaseNostrClientTest() {
@Test @Test
fun testDownloadFirstEvent() = fun testDownloadFirstEvent() =
runBlocking { 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 appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope) val client = NostrClient(socketBuilder, appScope)
val event = val event =
client.fetchFirst( client.fetchFirst(
relay = "wss://nos.lol", relay = relayUrl,
filter = filter =
Filter( Filter(
kinds = listOf(MetadataEvent.KIND), kinds = listOf(MetadataEvent.KIND),
authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), authors = listOf(pubKey),
), ),
) )
client.disconnect() client.disconnect()
appScope.cancel() appScope.cancel()
relayHub.close()
assertEquals(MetadataEvent.KIND, event?.kind) 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.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.testrelay.SyntheticEvents
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -41,6 +42,11 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
@Test @Test
fun testEoseAfter100Events() = fun testEoseAfter100Events() =
runBlocking { 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 appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope) val client = NostrClient(socketBuilder, appScope)
@@ -69,7 +75,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
val filters = val filters =
mapOf( mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to relayUrl to
listOf( listOf(
Filter( Filter(
kinds = listOf(MetadataEvent.KIND), kinds = listOf(MetadataEvent.KIND),
@@ -93,6 +99,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
client.disconnect() client.disconnect()
appScope.cancel() appScope.cancel()
relayHub.close()
assertEquals(101, events.size) assertEquals(101, events.size)
assertEquals(true, events.take(100).all { it.length == 64 }) assertEquals(true, events.take(100).all { it.length == 64 })
@@ -19,73 +19,97 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import junit.framework.TestCase.assertTrue import com.vitorpamplona.quartz.testrelay.SyntheticEvents
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals
class NostrClientQueryCountTest : BaseNostrClientTest() { class NostrClientQueryCountTest : BaseNostrClientTest() {
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl() private val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl()
val utxo = "wss://news.utxo.one".normalizeRelayUrl() private val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl()
val metadata = Filter(kinds = listOf(0)) private val metadata = Filter(kinds = listOf(0))
val outboxRelays = Filter(kinds = listOf(10002)) 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 @Test
fun testQueryCountSuspend() = fun testQueryCountSuspend() =
runBlocking { runBlocking {
seed()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope) 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() client.disconnect()
appScope.cancel() appScope.cancel()
relayHub.close()
} }
@Test @Test
fun testQueryCountSuspendAllEvents() = fun testQueryCountSuspendAllEvents() =
runBlocking { runBlocking {
seed()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope) 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() client.disconnect()
appScope.cancel() appScope.cancel()
relayHub.close()
} }
@Test @Test
fun testQueryCountSuspendMultipleRelays() = fun testQueryCountSuspendMultipleRelays() =
runBlocking { runBlocking {
seed()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope) val client = NostrClient(socketBuilder, appScope)
val results = val results =
client.count( client.count(
mapOf( mapOf(
fiatjaf to listOf(metadata, outboxRelays), relayA to listOf(metadata, outboxRelays),
utxo to listOf(metadata, outboxRelays), relayB to listOf(metadata, outboxRelays),
), ),
) )
results.forEach { (url, countResult) -> assertEquals(8, results[relayA]?.count)
println("${url.url}: ${countResult.count}") assertEquals(9, results[relayB]?.count)
assertTrue(countResult.count > 1)
}
client.disconnect() client.disconnect()
appScope.cancel() 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.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.testrelay.SyntheticEvents
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -47,6 +48,26 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
@Test @Test
fun testRepeatSubEvents() = fun testRepeatSubEvents() =
runBlocking { 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 appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope) val client = NostrClient(socketBuilder, appScope)
@@ -82,7 +103,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
val filters = val filters =
mapOf( mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to
listOf( listOf(
Filter( Filter(
kinds = listOf(MetadataEvent.KIND), kinds = listOf(MetadataEvent.KIND),
@@ -93,7 +114,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
val filtersShouldIgnore = val filtersShouldIgnore =
mapOf( mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to
listOf( listOf(
Filter( Filter(
kinds = listOf(AdvertisedRelayListEvent.KIND), kinds = listOf(AdvertisedRelayListEvent.KIND),
@@ -104,7 +125,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
val filtersShouldSendAfterEOSE = val filtersShouldSendAfterEOSE =
mapOf( mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to
listOf( listOf(
Filter( Filter(
kinds = listOf(AdvertisedRelayListEvent.KIND), kinds = listOf(AdvertisedRelayListEvent.KIND),
@@ -143,6 +164,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
client.disconnect() client.disconnect()
appScope.cancel() appScope.cancel()
relayHub.close()
// The relay may return up to limit events before EOSE; some relays return // The relay may return up to limit events before EOSE; some relays return
// one extra past the requested limit, so don't assert on the exact count. // one extra past the requested limit, so don't assert on the exact count.
@@ -19,12 +19,14 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.testrelay.SyntheticEvents
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -38,15 +40,26 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() {
@Test @Test
fun testDownloadFromRelayReturnsMetadataEvents() = fun testDownloadFromRelayReturnsMetadataEvents() =
runBlocking { 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 appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope) val client = NostrClient(socketBuilder, appScope)
val events = mutableListOf<Event>() val events = mutableListOf<Event>()
// nos.lol returns only 500 events per req
val totalFound = val totalFound =
client.fetchAllPages( client.fetchAllPages(
relay = "wss://nos.lol", relay = "ws://127.0.0.1:7770/",
filters = filters =
listOf( listOf(
Filter( Filter(
@@ -61,27 +74,45 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() {
client.disconnect() client.disconnect()
delay(500) delay(500)
appScope.cancel() appScope.cancel()
relayHub.close()
assertEquals(1000, totalFound, "Expected 1000 events from wss://nos.lol") assertEquals(1000, totalFound)
assertEquals(1000, events.size, "Events list should be 1000 events") assertEquals(1000, events.size)
events.forEach { event -> events.forEach { event ->
assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}") assertEquals(MetadataEvent.KIND, event.kind)
} }
} }
@Test @Test
fun testDownloadFromRelayReturnsMetadataAndContactListEvents() = fun testDownloadFromRelayReturnsMetadataAndContactListEvents() =
runBlocking { 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 appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope) val client = NostrClient(socketBuilder, appScope)
val metadataEvents = mutableListOf<Event>() val metadataEvents = mutableListOf<Event>()
val contactListEvents = mutableListOf<Event>() val contactListEvents = mutableListOf<Event>()
// nos.lol returns only 500 events per req
val totalFound = val totalFound =
client.fetchAllPages( client.fetchAllPages(
relay = "wss://nos.lol", relay = "ws://127.0.0.1:7770/",
filters = filters =
listOf( listOf(
Filter( Filter(
@@ -105,15 +136,10 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() {
client.disconnect() client.disconnect()
delay(500) delay(500)
appScope.cancel() appScope.cancel()
relayHub.close()
assertEquals(2500, totalFound, "Expected 1000 events from wss://nos.lol") assertEquals(2500, totalFound)
assertEquals(1000, metadataEvents.size, "Events list should be 1000 events") assertEquals(1000, metadataEvents.size)
assertEquals(1500, contactListEvents.size, "Events list should be 1000 events") assertEquals(1500, contactListEvents.size)
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}")
}
} }
} }
@@ -44,22 +44,26 @@ class NostrClientSendAndWaitTest : BaseNostrClientTest() {
val event = randomSigner.sign(TextNoteEvent.build("Hello World")) 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( client.publishAndConfirm(
event = event, event = event,
relayList = setOf("wss://nostr.bitcoiner.social".normalizeRelayUrl()), relayList = setOf(relayA),
) )
val resultNos = val resultB =
client.publishAndConfirm( client.publishAndConfirm(
event = event, event = event,
relayList = setOf("wss://nos.lol".normalizeRelayUrl()), relayList = setOf(relayB),
) )
client.disconnect() client.disconnect()
appScope.cancel() appScope.cancel()
relayHub.close()
assertEquals(true, resultDamus) assertEquals(true, resultA)
assertEquals(true, resultNos) 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.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.testrelay.SyntheticEvents
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -48,12 +49,15 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
@Test @Test
fun testNostrClientSubscriptionAsFlow() = fun testNostrClientSubscriptionAsFlow() =
runTest { runTest {
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload(
SyntheticEvents.batch(20, kind = MetadataEvent.KIND),
)
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope) val client = NostrClient(socketBuilder, appScope)
val flow = val flow =
client.subscribeAsFlow( client.subscribeAsFlow(
relay = "wss://nos.lol", relay = "ws://127.0.0.1:7770/",
filter = filter =
Filter( Filter(
kinds = listOf(MetadataEvent.KIND), kinds = listOf(MetadataEvent.KIND),
@@ -79,6 +83,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
client.disconnect() client.disconnect()
appScope.cancel() appScope.cancel()
relayHub.close()
assertEquals(10, feedStates.size) assertEquals(10, feedStates.size)
} }
@@ -87,12 +92,15 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
@Test @Test
fun testNostrClientSubscriptionAsFlowDebouncing() = fun testNostrClientSubscriptionAsFlowDebouncing() =
runTest { runTest {
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload(
SyntheticEvents.batch(20, kind = MetadataEvent.KIND),
)
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope) val client = NostrClient(socketBuilder, appScope)
val flow = val flow =
client.subscribeAsFlow( client.subscribeAsFlow(
relay = "wss://nos.lol", relay = "ws://127.0.0.1:7770/",
filter = filter =
Filter( Filter(
kinds = listOf(MetadataEvent.KIND), kinds = listOf(MetadataEvent.KIND),
@@ -118,6 +126,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
client.disconnect() client.disconnect()
appScope.cancel() appScope.cancel()
relayHub.close()
assertEquals(10, feedStates.size) 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.client.reqs.StaticSubscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.testrelay.SyntheticEvents
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -40,6 +41,9 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() {
@Test @Test
fun testNostrClientSubscription() = fun testNostrClientSubscription() =
runBlocking { runBlocking {
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload(
SyntheticEvents.batch(150, kind = MetadataEvent.KIND),
)
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope) val client = NostrClient(socketBuilder, appScope)
@@ -50,7 +54,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() {
StaticSubscription( StaticSubscription(
client, client,
mapOf( mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to
listOf( listOf(
Filter( Filter(
kinds = listOf(MetadataEvent.KIND), kinds = listOf(MetadataEvent.KIND),
@@ -76,6 +80,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() {
client.disconnect() client.disconnect()
appScope.cancel() appScope.cancel()
relayHub.close()
assertEquals(100, events.size) 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.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.testrelay.SyntheticEvents
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -48,12 +49,15 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
@Test @Test
fun testNostrClientSubscriptionUntilEoseAsFlow() = fun testNostrClientSubscriptionUntilEoseAsFlow() =
runTest { runTest {
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload(
SyntheticEvents.batch(20, kind = MetadataEvent.KIND),
)
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope) val client = NostrClient(socketBuilder, appScope)
val flow = val flow =
client.fetchAsFlow( client.fetchAsFlow(
relay = "wss://nos.lol", relay = "ws://127.0.0.1:7770/",
filter = filter =
Filter( Filter(
kinds = listOf(MetadataEvent.KIND), kinds = listOf(MetadataEvent.KIND),
@@ -79,6 +83,7 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
client.disconnect() client.disconnect()
appScope.cancel() appScope.cancel()
relayHub.close()
assertEquals(10, feedStates.size) assertEquals(10, feedStates.size)
} }
@@ -87,12 +92,15 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
@Test @Test
fun testNostrClientSubscriptionUntilEoseAsFlowDebouncing() = fun testNostrClientSubscriptionUntilEoseAsFlowDebouncing() =
runTest { runTest {
relayHub.getOrCreate("ws://127.0.0.1:7770/").preload(
SyntheticEvents.batch(20, kind = MetadataEvent.KIND),
)
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope) val client = NostrClient(socketBuilder, appScope)
val flow = val flow =
client.fetchAsFlow( client.fetchAsFlow(
relay = "wss://nos.lol", relay = "ws://127.0.0.1:7770/",
filter = filter =
Filter( Filter(
kinds = listOf(MetadataEvent.KIND), kinds = listOf(MetadataEvent.KIND),
@@ -118,6 +126,7 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
client.disconnect() client.disconnect()
appScope.cancel() appScope.cancel()
relayHub.close()
assertEquals(10, feedStates.size) assertEquals(10, feedStates.size)
} }
+1
View File
@@ -34,6 +34,7 @@ rootProject.name = "Amethyst"
include ':amethyst' include ':amethyst'
include ':benchmark' include ':benchmark'
include ':quartz' include ':quartz'
include ':quartz-test-relay'
include ':commons' include ':commons'
include ':ammolite' include ':ammolite'
include ':quic' include ':quic'