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:
@@ -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)
|
||||
}
|
||||
+79
@@ -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"))
|
||||
}
|
||||
+66
@@ -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()
|
||||
}
|
||||
}
|
||||
+409
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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)) }
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
+12
-12
@@ -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()
|
||||
|
||||
+3
-3
@@ -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()
|
||||
}
|
||||
|
||||
+4
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+15
-29
@@ -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
|
||||
}
|
||||
|
||||
+21
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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 })
|
||||
|
||||
+39
-15
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+25
-3
@@ -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.
|
||||
|
||||
+42
-16
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -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)
|
||||
}
|
||||
|
||||
+6
-1
@@ -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)
|
||||
}
|
||||
|
||||
+11
-2
@@ -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)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ rootProject.name = "Amethyst"
|
||||
include ':amethyst'
|
||||
include ':benchmark'
|
||||
include ':quartz'
|
||||
include ':quartz-test-relay'
|
||||
include ':commons'
|
||||
include ':ammolite'
|
||||
include ':quic'
|
||||
|
||||
Reference in New Issue
Block a user