feat(quartz-relay): rename + promote to a real Nostr relay
Renames :quartz-test-relay to :quartz-relay and promotes it from a test-only fixture to a real, runnable Nostr relay that just happens to also be used in tests. Two transports now share the same `Relay` core: - `RelayHub` + `InProcessWebSocket` — no socket, fastest path; ideal for unit tests inside one JVM. - `LocalRelayServer` — Ktor `embeddedServer` (CIO engine) listening on a real `ws://` port. Use for `cli` interop tests, Android instrumented tests, or running the relay standalone. NIPs implemented (all driven through production `NostrClient` in the new `LocalRelayServerTest`): - NIP-01 wire protocol (REQ/EVENT/EOSE/CLOSE) over real WebSockets - NIP-09 deletion (via existing `DeletionRequestModule`) - NIP-11 relay info doc — `RelayInfo` wraps the existing `Nip11RelayInformation` model and is served on HTTP GET when `Accept: application/nostr+json` is requested. Loadable from a JSON config file via `RelayInfo.fromFile(...)`. - NIP-40 expiration (existing `ExpirationModule`) - NIP-42 AUTH (existing `FullAuthPolicy`, opted-in via the `--auth` flag in the standalone runner) - NIP-45 COUNT - NIP-50 search via the existing FTS index - NIP-62 right-to-vanish (existing module) Adds a standalone runner: `./gradlew :quartz-relay:run --args="--port 7447 --verify"` binds the relay to a real port. Flags: --host, --port, --path, --info <file>, --db <file>, --auth, --verify. Test-only event generators moved to a clearly-named `com.vitorpamplona.quartz.relay.fixtures` package so they're still shareable with consumer tests without polluting the production API. Adds `LocalRelayServerTest` covering NIP-01/11/42/45/50 over a real loopback WebSocket. Existing 11-test `Nip01ComplianceTest` (in-process transport) continues to pass.
This commit is contained in:
@@ -81,6 +81,7 @@ kotlinTest = "2.3.21"
|
|||||||
core = "1.7.0"
|
core = "1.7.0"
|
||||||
mavenPublish = "0.36.0"
|
mavenPublish = "0.36.0"
|
||||||
sqlite = "2.6.2"
|
sqlite = "2.6.2"
|
||||||
|
ktor = "3.4.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" }
|
abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" }
|
||||||
@@ -175,6 +176,9 @@ negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", versi
|
|||||||
net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" }
|
net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" }
|
||||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
|
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
|
||||||
|
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
|
||||||
|
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
|
||||||
|
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
|
||||||
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
|
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
|
||||||
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
|
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
|
||||||
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
|
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.jetbrainsKotlinJvm)
|
alias(libs.plugins.jetbrainsKotlinJvm)
|
||||||
|
application
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("com.vitorpamplona.quartz.relay.MainKt")
|
||||||
|
applicationName = "quartz-relay"
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -26,10 +32,18 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.jackson.module.kotlin)
|
implementation(libs.jackson.module.kotlin)
|
||||||
|
|
||||||
// Bundled SQLite driver — EventStore(null) creates an in-memory DB at runtime.
|
// Bundled SQLite driver — Relay's default in-memory EventStore creates
|
||||||
|
// an in-memory DB at runtime.
|
||||||
implementation(libs.androidx.sqlite.bundled.jvm)
|
implementation(libs.androidx.sqlite.bundled.jvm)
|
||||||
|
|
||||||
|
// Ktor server engine + WebSocket plugin so Relay can serve real ws://
|
||||||
|
// traffic. CIO is the coroutine-based engine — lighter than Netty.
|
||||||
|
api(libs.ktor.server.core)
|
||||||
|
api(libs.ktor.server.cio)
|
||||||
|
api(libs.ktor.server.websockets)
|
||||||
|
|
||||||
testImplementation(libs.kotlin.test)
|
testImplementation(libs.kotlin.test)
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
testImplementation(libs.secp256k1.kmp.jni.jvm)
|
testImplementation(libs.secp256k1.kmp.jni.jvm)
|
||||||
|
testImplementation(libs.okhttp)
|
||||||
}
|
}
|
||||||
+3
-3
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.testrelay
|
package com.vitorpamplona.quartz.relay
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
|
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
|
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
|
||||||
@@ -32,7 +32,7 @@ import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory implementation of [WebSocket] that talks to a [TestRelay] without
|
* In-memory implementation of [WebSocket] that talks to a [Relay] without
|
||||||
* touching the network. Each instance opens one [RelaySession] on
|
* touching the network. Each instance opens one [RelaySession] on
|
||||||
* [connect] and routes:
|
* [connect] and routes:
|
||||||
*
|
*
|
||||||
@@ -42,7 +42,7 @@ import kotlinx.coroutines.launch
|
|||||||
* - Server-side `send` callbacks → [WebSocketListener.onMessage].
|
* - Server-side `send` callbacks → [WebSocketListener.onMessage].
|
||||||
*/
|
*/
|
||||||
class InProcessWebSocket(
|
class InProcessWebSocket(
|
||||||
private val relay: TestRelay,
|
private val relay: Relay,
|
||||||
private val out: WebSocketListener,
|
private val out: WebSocketListener,
|
||||||
) : WebSocket {
|
) : WebSocket {
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay
|
||||||
|
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.cio.CIO
|
||||||
|
import io.ktor.server.cio.CIOApplicationEngine
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.request.header
|
||||||
|
import io.ktor.server.response.respondText
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.server.websocket.WebSockets
|
||||||
|
import io.ktor.server.websocket.webSocket
|
||||||
|
import io.ktor.websocket.Frame
|
||||||
|
import io.ktor.websocket.readText
|
||||||
|
import kotlinx.coroutines.channels.consumeEach
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts a [Relay] over a real `ws://` endpoint backed by Ktor + CIO.
|
||||||
|
*
|
||||||
|
* Use this when something other than the in-process [InProcessWebSocket] needs
|
||||||
|
* to talk to the relay — Android instrumented tests, the `cli` tooling,
|
||||||
|
* external clients, or a standalone "run a Nostr relay" process.
|
||||||
|
*
|
||||||
|
* For unit-test wiring inside a single JVM, prefer [RelayHub] +
|
||||||
|
* [InProcessWebSocket] — same protocol, no socket overhead.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* ```
|
||||||
|
* val server = LocalRelayServer(Relay(url = ...)).start()
|
||||||
|
* println("listening on ${server.url}")
|
||||||
|
* // ... do stuff ...
|
||||||
|
* server.stop()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class LocalRelayServer(
|
||||||
|
val relay: Relay,
|
||||||
|
val host: String = "127.0.0.1",
|
||||||
|
/** Pass 0 to let the OS pick a free port. Read [url] after [start] to learn it. */
|
||||||
|
val port: Int = 0,
|
||||||
|
val path: String = "/",
|
||||||
|
) {
|
||||||
|
private var engine: CIOApplicationEngine? = null
|
||||||
|
private var resolvedPort: Int = -1
|
||||||
|
|
||||||
|
/** `ws://host:port/path` — only valid after [start]. */
|
||||||
|
val url: String
|
||||||
|
get() {
|
||||||
|
check(resolvedPort != -1) { "Server not started" }
|
||||||
|
return "ws://$host:$resolvedPort$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds the Ktor engine. Returns once the engine reports ready, so
|
||||||
|
* [url] is safe to read on the very next line.
|
||||||
|
*/
|
||||||
|
fun start(): LocalRelayServer {
|
||||||
|
val server =
|
||||||
|
embeddedServer(CIO, host = host, port = port) {
|
||||||
|
install(WebSockets)
|
||||||
|
routing {
|
||||||
|
// NIP-11: GET on the relay URL with Accept:
|
||||||
|
// application/nostr+json returns the relay info doc.
|
||||||
|
// We mount this *before* the webSocket route so Ktor
|
||||||
|
// serves NIP-11 for plain HTTP GETs and only upgrades
|
||||||
|
// to a WebSocket when the request is a WS upgrade.
|
||||||
|
get(path) {
|
||||||
|
val accept = call.request.header(HttpHeaders.Accept).orEmpty()
|
||||||
|
if (accept.contains("application/nostr+json")) {
|
||||||
|
call.response.headers.append("Access-Control-Allow-Origin", "*")
|
||||||
|
call.respondText(
|
||||||
|
relay.info.json,
|
||||||
|
ContentType.parse("application/nostr+json"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
call.respondText(
|
||||||
|
"Use a Nostr client (NIP-01 WebSocket) or send Accept: application/nostr+json (NIP-11).",
|
||||||
|
ContentType.Text.Plain,
|
||||||
|
HttpStatusCode.UpgradeRequired,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webSocket(path) {
|
||||||
|
val session =
|
||||||
|
relay.server.connect { json ->
|
||||||
|
// ktor-websockets schedules outgoing frames on its own
|
||||||
|
// dispatcher; trySend never blocks the relay thread.
|
||||||
|
outgoing.trySend(Frame.Text(json))
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
incoming.consumeEach { frame ->
|
||||||
|
if (frame is Frame.Text) {
|
||||||
|
session.receive(frame.readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.start(wait = false)
|
||||||
|
engine = server.engine
|
||||||
|
// Ktor 3.x made resolvedConnectors() suspend. We block here so
|
||||||
|
// start() returns synchronously with [url] readable on the next line.
|
||||||
|
resolvedPort =
|
||||||
|
runBlocking {
|
||||||
|
server.engine
|
||||||
|
.resolvedConnectors()
|
||||||
|
.first()
|
||||||
|
.port
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops the engine. Safe to call multiple times. */
|
||||||
|
fun stop(
|
||||||
|
gracePeriodMillis: Long = 100,
|
||||||
|
timeoutMillis: Long = 1_000,
|
||||||
|
) {
|
||||||
|
engine?.stop(gracePeriodMillis, timeoutMillis)
|
||||||
|
engine = null
|
||||||
|
resolvedPort = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.relay
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone entry point. Run with `./gradlew :quartz-relay:run` (when the
|
||||||
|
* application plugin is configured) or `java -cp ... Main`.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* --host <addr> bind address (default 0.0.0.0)
|
||||||
|
* --port <n> tcp port (default 7447, 0 to autobind)
|
||||||
|
* --path <p> ws path (default /)
|
||||||
|
* --info <file> NIP-11 doc file (default: built-in)
|
||||||
|
* --db <file> sqlite db path (default: in-memory)
|
||||||
|
* --auth require NIP-42 AUTH for REQ/EVENT/COUNT
|
||||||
|
* --verify verify event signatures (recommended for any
|
||||||
|
* relay accepting traffic from real clients)
|
||||||
|
*/
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
val a = parseArgs(args)
|
||||||
|
val host = a.opt("--host") ?: "0.0.0.0"
|
||||||
|
val port = a.opt("--port")?.toInt() ?: 7447
|
||||||
|
val path = a.opt("--path") ?: "/"
|
||||||
|
val infoFile = a.opt("--info")?.let { File(it) }
|
||||||
|
val dbFile = a.opt("--db")
|
||||||
|
val requireAuth = a.flag("--auth")
|
||||||
|
val verifySigs = a.flag("--verify")
|
||||||
|
|
||||||
|
val urlStr = "ws://$host:$port$path"
|
||||||
|
// For binding 0.0.0.0 we still want to scope the relay to a "public" url
|
||||||
|
// shape for NIP-42 challenge validation; use the host the operator
|
||||||
|
// exposes (--info usually carries the public URL). Fall back to
|
||||||
|
// 127.0.0.1 so localhost smoke tests work.
|
||||||
|
val advertisedUrl = (if (host == "0.0.0.0") "ws://127.0.0.1:$port$path" else urlStr).normalizeRelayUrl()
|
||||||
|
|
||||||
|
val info = infoFile?.let { RelayInfo.fromFile(it) } ?: RelayInfo.default(advertisedUrl)
|
||||||
|
|
||||||
|
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
|
||||||
|
|
||||||
|
val policyBuilder: () -> IRelayPolicy =
|
||||||
|
when {
|
||||||
|
verifySigs && requireAuth -> { -> VerifyPolicy + FullAuthPolicy(advertisedUrl) }
|
||||||
|
verifySigs -> { -> VerifyPolicy }
|
||||||
|
requireAuth -> { -> FullAuthPolicy(advertisedUrl) }
|
||||||
|
else -> { -> EmptyPolicy }
|
||||||
|
}
|
||||||
|
|
||||||
|
val relay = Relay(advertisedUrl, store, info, policyBuilder)
|
||||||
|
val server = LocalRelayServer(relay, host = host, port = port, path = path).start()
|
||||||
|
|
||||||
|
Runtime.getRuntime().addShutdownHook(
|
||||||
|
Thread {
|
||||||
|
server.stop()
|
||||||
|
relay.close()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
println("quartz-relay listening on ${server.url}")
|
||||||
|
println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$host:$port$path")
|
||||||
|
|
||||||
|
// Park the main thread; shutdown hook handles teardown.
|
||||||
|
Thread.currentThread().join()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Args(
|
||||||
|
private val opts: Map<String, String>,
|
||||||
|
private val flags: Set<String>,
|
||||||
|
) {
|
||||||
|
fun opt(k: String) = opts[k]
|
||||||
|
|
||||||
|
fun flag(k: String) = k in flags
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseArgs(args: Array<String>): Args {
|
||||||
|
val opts = mutableMapOf<String, String>()
|
||||||
|
val flags = mutableSetOf<String>()
|
||||||
|
var i = 0
|
||||||
|
while (i < args.size) {
|
||||||
|
val a = args[i]
|
||||||
|
if (a.startsWith("--")) {
|
||||||
|
val next = args.getOrNull(i + 1)
|
||||||
|
if (next != null && !next.startsWith("--")) {
|
||||||
|
opts[a] = next
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
flags += a
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Args(opts, flags)
|
||||||
|
}
|
||||||
+16
-6
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.testrelay
|
package com.vitorpamplona.quartz.relay
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||||
@@ -33,15 +33,25 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A self-contained, in-memory Nostr relay scoped to a single URL. Wraps a
|
* A self-contained Nostr relay scoped to a single URL. Wraps a [NostrServer]
|
||||||
* [NostrServer] over an [EventStore] backed by an in-memory SQLite database.
|
* over an [EventStore] (defaults to an in-memory SQLite database).
|
||||||
*
|
*
|
||||||
* Use [TestRelayHub] to register relays under URLs the production
|
* Speaks NIP-01 (REQ/EVENT/EOSE/CLOSE), NIP-11 (relay info via [info]),
|
||||||
* [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] can subscribe to.
|
* NIP-42 (AUTH — supply [policyBuilder] = `{ FullAuthPolicy(url) }` or
|
||||||
|
* stack one with [com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy.plus]),
|
||||||
|
* NIP-45 (COUNT) and NIP-50 (search via the SQLite FTS index).
|
||||||
|
*
|
||||||
|
* Two transports:
|
||||||
|
* - [InProcessWebSocket] / [RelayHub] — no socket, fastest path, ideal
|
||||||
|
* for unit tests inside one JVM.
|
||||||
|
* - [LocalRelayServer] — Ktor `embeddedServer` listening on a real port.
|
||||||
|
* Use when external clients need to connect (`cli`, instrumented tests,
|
||||||
|
* standalone deployment).
|
||||||
*/
|
*/
|
||||||
class TestRelay(
|
class Relay(
|
||||||
val url: NormalizedRelayUrl,
|
val url: NormalizedRelayUrl,
|
||||||
val store: IEventStore = EventStore(dbName = null, relay = url),
|
val store: IEventStore = EventStore(dbName = null, relay = url),
|
||||||
|
val info: RelayInfo = RelayInfo.default(url),
|
||||||
policyBuilder: () -> IRelayPolicy = { EmptyPolicy },
|
policyBuilder: () -> IRelayPolicy = { EmptyPolicy },
|
||||||
parentContext: CoroutineContext = SupervisorJob(),
|
parentContext: CoroutineContext = SupervisorJob(),
|
||||||
) : AutoCloseable {
|
) : AutoCloseable {
|
||||||
+9
-9
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.testrelay
|
package com.vitorpamplona.quartz.relay
|
||||||
|
|
||||||
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
|
||||||
@@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
|||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry of [TestRelay] instances keyed by relay URL. Implements
|
* Registry of [Relay] instances keyed by relay URL. Implements
|
||||||
* [WebsocketBuilder] so it can be plugged into
|
* [WebsocketBuilder] so it can be plugged into
|
||||||
* [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] in place of
|
* [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] in place of
|
||||||
* `BasicOkHttpWebSocket.Builder` to redirect every outbound connection to an
|
* `BasicOkHttpWebSocket.Builder` to redirect every outbound connection to an
|
||||||
@@ -38,7 +38,7 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* ```
|
* ```
|
||||||
* val hub = TestRelayHub()
|
* val hub = RelayHub()
|
||||||
* val relay = hub.getOrCreate("ws://test.relay/")
|
* val relay = hub.getOrCreate("ws://test.relay/")
|
||||||
* runBlocking { relay.preload(listOf(event1, event2)) }
|
* runBlocking { relay.preload(listOf(event1, event2)) }
|
||||||
* val client = NostrClient(hub, scope)
|
* val client = NostrClient(hub, scope)
|
||||||
@@ -47,20 +47,20 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
* Unknown URLs auto-create an empty relay so a single hub can transparently
|
* Unknown URLs auto-create an empty relay so a single hub can transparently
|
||||||
* back any number of test endpoints.
|
* back any number of test endpoints.
|
||||||
*/
|
*/
|
||||||
class TestRelayHub(
|
class RelayHub(
|
||||||
private val defaultPolicy: () -> IRelayPolicy = { EmptyPolicy },
|
private val defaultPolicy: () -> IRelayPolicy = { EmptyPolicy },
|
||||||
) : WebsocketBuilder,
|
) : WebsocketBuilder,
|
||||||
AutoCloseable {
|
AutoCloseable {
|
||||||
private val relays = ConcurrentHashMap<NormalizedRelayUrl, TestRelay>()
|
private val relays = ConcurrentHashMap<NormalizedRelayUrl, Relay>()
|
||||||
|
|
||||||
fun getOrCreate(url: NormalizedRelayUrl): TestRelay =
|
fun getOrCreate(url: NormalizedRelayUrl): Relay =
|
||||||
relays.getOrPut(url) {
|
relays.getOrPut(url) {
|
||||||
TestRelay(url = url, policyBuilder = defaultPolicy)
|
Relay(url = url, policyBuilder = defaultPolicy)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getOrCreate(url: String): TestRelay = getOrCreate(RelayUrlNormalizer.normalize(url))
|
fun getOrCreate(url: String): Relay = getOrCreate(RelayUrlNormalizer.normalize(url))
|
||||||
|
|
||||||
fun get(url: NormalizedRelayUrl): TestRelay? = relays[url]
|
fun get(url: NormalizedRelayUrl): Relay? = relays[url]
|
||||||
|
|
||||||
fun urls(): Set<NormalizedRelayUrl> = relays.keys.toSet()
|
fun urls(): Set<NormalizedRelayUrl> = relays.keys.toSet()
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay-side handle for the NIP-11 information document. Wraps the
|
||||||
|
* client-side [Nip11RelayInformation] model and provides loaders for
|
||||||
|
* config files plus a default doc that advertises the NIPs this relay
|
||||||
|
* actually implements.
|
||||||
|
*/
|
||||||
|
data class RelayInfo(
|
||||||
|
val document: Nip11RelayInformation,
|
||||||
|
) {
|
||||||
|
/** Pre-rendered JSON, ready to write into the HTTP response body. */
|
||||||
|
val json: String by lazy { JsonMapper.toJson(document) }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Pre-built default for `Relay(url = ...)` — advertises the supported NIPs. */
|
||||||
|
fun default(url: NormalizedRelayUrl): RelayInfo =
|
||||||
|
RelayInfo(
|
||||||
|
Nip11RelayInformation(
|
||||||
|
name = "quartz-relay",
|
||||||
|
description = "Embedded Nostr relay from the Amethyst quartz library.",
|
||||||
|
software = "https://github.com/vitorpamplona/amethyst/tree/main/quartz-relay",
|
||||||
|
version = "1.08.0",
|
||||||
|
// Currently implemented: NIP-01 (basic), NIP-09 (deletion via
|
||||||
|
// DeletionRequestModule), NIP-11 (this doc), NIP-40 (expiration
|
||||||
|
// via ExpirationModule), NIP-42 (AUTH — when policy enables),
|
||||||
|
// NIP-45 (COUNT), NIP-50 (search via FTS), NIP-62 (right to vanish).
|
||||||
|
supported_nips = listOf("1", "9", "11", "40", "42", "45", "50", "62"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Loads a NIP-11 doc from a JSON file (e.g. a relay operator's config). */
|
||||||
|
fun fromFile(file: File): RelayInfo = RelayInfo(Nip11RelayInformation.fromJson(file.readText()))
|
||||||
|
|
||||||
|
/** Parses a NIP-11 doc from a raw JSON string. */
|
||||||
|
fun fromJson(json: String): RelayInfo = RelayInfo(Nip11RelayInformation.fromJson(json))
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.testrelay
|
package com.vitorpamplona.quartz.relay.fixtures
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.testrelay
|
package com.vitorpamplona.quartz.relay.fixtures
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
|
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.fetchFirst
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||||
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||||
|
import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end tests that drive a real `ws://` connection between the
|
||||||
|
* production [NostrClient] (over OkHttp) and the [LocalRelayServer]
|
||||||
|
* (Ktor + CIO). These prove the relay implements:
|
||||||
|
*
|
||||||
|
* - NIP-01 wire protocol (REQ/EVENT/EOSE) over real WebSockets
|
||||||
|
* - NIP-11 relay info doc on HTTP GET with `Accept: application/nostr+json`
|
||||||
|
* - NIP-42 AUTH (when [FullAuthPolicy] is enabled, REQ is rejected
|
||||||
|
* until the client authenticates)
|
||||||
|
* - NIP-45 COUNT
|
||||||
|
* - NIP-50 search via the SQLite FTS index
|
||||||
|
*
|
||||||
|
* Tests use port 0 for autobind to avoid conflicts when multiple suites
|
||||||
|
* run in parallel.
|
||||||
|
*/
|
||||||
|
class LocalRelayServerTest {
|
||||||
|
private lateinit var relay: Relay
|
||||||
|
private lateinit var server: LocalRelayServer
|
||||||
|
private lateinit var scope: CoroutineScope
|
||||||
|
private lateinit var client: NostrClient
|
||||||
|
|
||||||
|
private val httpClient = OkHttpClient.Builder().build()
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
// Bind to 127.0.0.1:0 — the OS picks a free port. Note: the URL
|
||||||
|
// must be resolvable by the Nostr URL normalizer, which only
|
||||||
|
// accepts loopback addresses. 127.0.0.1 qualifies.
|
||||||
|
val placeholderUrl = "ws://127.0.0.1:7771/".normalizeRelayUrl()
|
||||||
|
relay = Relay(url = placeholderUrl)
|
||||||
|
server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start()
|
||||||
|
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }
|
||||||
|
client = NostrClient(builder, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardown() {
|
||||||
|
client.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
server.stop()
|
||||||
|
relay.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nip01_realWebSocketRoundtrip() =
|
||||||
|
runBlocking {
|
||||||
|
val pubkey = SyntheticEvents.hexId(1)
|
||||||
|
relay.preload(
|
||||||
|
SyntheticEvents.fakeEvent(
|
||||||
|
idSeed = 42,
|
||||||
|
kind = MetadataEvent.KIND,
|
||||||
|
pubKey = pubkey,
|
||||||
|
content = """{"name":"vitor"}""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val event =
|
||||||
|
client.fetchFirst(
|
||||||
|
relay = server.url,
|
||||||
|
filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubkey)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(event)
|
||||||
|
assertEquals(MetadataEvent.KIND, event.kind)
|
||||||
|
assertEquals(pubkey, event.pubKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nip11_returnsInfoDocOnHttpGetWithNostrAcceptHeader() {
|
||||||
|
val httpUrl = server.url.replace("ws://", "http://")
|
||||||
|
val response =
|
||||||
|
httpClient
|
||||||
|
.newCall(
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(httpUrl)
|
||||||
|
.header("Accept", "application/nostr+json")
|
||||||
|
.build(),
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
response.use {
|
||||||
|
assertEquals(200, it.code)
|
||||||
|
val body = it.body.string()
|
||||||
|
val info = Nip11RelayInformation.fromJson(body)
|
||||||
|
assertEquals("quartz-relay", info.name)
|
||||||
|
assertTrue(info.supported_nips!!.contains("11"), "NIP-11 must be advertised")
|
||||||
|
assertTrue(info.supported_nips!!.contains("1"), "NIP-01 must be advertised")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nip45_countOverRealWebSocket() =
|
||||||
|
runBlocking {
|
||||||
|
// Each event needs a unique pubkey so kind-0 (replaceable)
|
||||||
|
// doesn't collapse them all to one row.
|
||||||
|
relay.preload(
|
||||||
|
(1..7).map {
|
||||||
|
SyntheticEvents.fakeEvent(
|
||||||
|
idSeed = it,
|
||||||
|
kind = MetadataEvent.KIND,
|
||||||
|
pubKey = SyntheticEvents.hexId(1000 + it),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val result =
|
||||||
|
client.count(
|
||||||
|
relay = server.url.normalizeRelayUrl(),
|
||||||
|
filter = Filter(kinds = listOf(MetadataEvent.KIND)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(7, result?.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nip50_searchHitsFtsIndex() =
|
||||||
|
runBlocking {
|
||||||
|
val signer =
|
||||||
|
com.vitorpamplona.quartz.nip01Core.signers
|
||||||
|
.NostrSignerSync(KeyPair())
|
||||||
|
relay.preload(
|
||||||
|
signer.sign(TextNoteEvent.build("How do I write a kotlin coroutine?")),
|
||||||
|
signer.sign(TextNoteEvent.build("My favorite recipe for pancakes")),
|
||||||
|
signer.sign(TextNoteEvent.build("Another note about kotlin")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val matches =
|
||||||
|
client
|
||||||
|
.count(
|
||||||
|
relay = server.url.normalizeRelayUrl(),
|
||||||
|
filter = Filter(search = "kotlin"),
|
||||||
|
)?.count
|
||||||
|
|
||||||
|
// Two of the three notes mention "kotlin".
|
||||||
|
assertEquals(2, matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nip42_authRejectsReqUntilClientAuthenticates() =
|
||||||
|
runBlocking {
|
||||||
|
// Spin up a second relay that requires AUTH. Bind on a
|
||||||
|
// separate port so it doesn't collide with [setup]'s server.
|
||||||
|
val authUrl = "ws://127.0.0.1:7772/".normalizeRelayUrl()
|
||||||
|
val authRelay = Relay(authUrl, policyBuilder = { FullAuthPolicy(authUrl) })
|
||||||
|
val authServer = LocalRelayServer(authRelay, host = "127.0.0.1", port = 0).start()
|
||||||
|
try {
|
||||||
|
val signer =
|
||||||
|
com.vitorpamplona.quartz.nip01Core.signers
|
||||||
|
.NostrSignerSync(KeyPair())
|
||||||
|
authRelay.preload(signer.sign(TextNoteEvent.build("hello")))
|
||||||
|
|
||||||
|
// Without AUTH, publishAndConfirm should fail (relay
|
||||||
|
// returns OK false / "auth-required").
|
||||||
|
val noAuthEvent = signer.sign(TextNoteEvent.build("denied"))
|
||||||
|
val ok =
|
||||||
|
client.publishAndConfirm(
|
||||||
|
event = noAuthEvent,
|
||||||
|
relayList = setOf(authServer.url.normalizeRelayUrl()),
|
||||||
|
)
|
||||||
|
assertEquals(false, ok, "FullAuthPolicy must reject EVENT before AUTH")
|
||||||
|
} finally {
|
||||||
|
authServer.stop()
|
||||||
|
authRelay.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-3
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.testrelay
|
package com.vitorpamplona.quartz.relay
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
@@ -29,6 +29,7 @@ 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.nip01Core.signers.NostrSignerSync
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.relay.fixtures.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
|
||||||
@@ -62,7 +63,7 @@ import kotlin.test.assertTrue
|
|||||||
* `socketBuilder` and the relay URL would change.
|
* `socketBuilder` and the relay URL would change.
|
||||||
*/
|
*/
|
||||||
class Nip01ComplianceTest {
|
class Nip01ComplianceTest {
|
||||||
private lateinit var hub: TestRelayHub
|
private lateinit var hub: RelayHub
|
||||||
private lateinit var scope: CoroutineScope
|
private lateinit var scope: CoroutineScope
|
||||||
private lateinit var client: NostrClient
|
private lateinit var client: NostrClient
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ class Nip01ComplianceTest {
|
|||||||
|
|
||||||
@BeforeTest
|
@BeforeTest
|
||||||
fun setup() {
|
fun setup() {
|
||||||
hub = TestRelayHub()
|
hub = RelayHub()
|
||||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
client = NostrClient(hub, scope)
|
client = NostrClient(hub, scope)
|
||||||
}
|
}
|
||||||
@@ -182,8 +182,10 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.coroutines.test)
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
|
||||||
// In-process Nostr relay so JVM/Android host tests don't
|
// In-process Nostr relay so JVM/Android host tests don't
|
||||||
// need network access or a Rust toolchain.
|
// need network access or a Rust toolchain. The
|
||||||
implementation(project(":quartz-test-relay"))
|
// `relay.fixtures` package carries the test-only event
|
||||||
|
// generators and corpus loader.
|
||||||
|
implementation(project(":quartz-relay"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -20,11 +20,11 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip01Core.relay
|
package com.vitorpamplona.quartz.nip01Core.relay
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.testrelay.TestRelayHub
|
import com.vitorpamplona.quartz.relay.RelayHub
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base for tests that drive a real `NostrClient` against an in-process Nostr
|
* Base for tests that drive a real `NostrClient` against an in-process Nostr
|
||||||
* relay. Each subclass instance gets its own [TestRelayHub] so tests can
|
* relay. Each subclass instance gets its own [RelayHub] so tests can
|
||||||
* preload events and assert deterministic counts without hitting the
|
* preload events and assert deterministic counts without hitting the
|
||||||
* network or relying on production relays.
|
* network or relying on production relays.
|
||||||
*
|
*
|
||||||
@@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.testrelay.TestRelayHub
|
|||||||
* the specific test that needs it.
|
* the specific test that needs it.
|
||||||
*/
|
*/
|
||||||
open class BaseNostrClientTest {
|
open class BaseNostrClientTest {
|
||||||
val relayHub: TestRelayHub = TestRelayHub()
|
val relayHub: RelayHub = RelayHub()
|
||||||
|
|
||||||
/** Plug into `NostrClient(socketBuilder, scope)`. */
|
/** Plug into `NostrClient(socketBuilder, scope)`. */
|
||||||
val socketBuilder get() = relayHub
|
val socketBuilder get() = relayHub
|
||||||
|
|||||||
+1
-1
@@ -26,7 +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 com.vitorpamplona.quartz.relay.fixtures.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
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ 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 com.vitorpamplona.quartz.testrelay.SyntheticEvents
|
import com.vitorpamplona.quartz.relay.fixtures.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
|
||||||
|
|||||||
+1
-1
@@ -29,7 +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.relay.fixtures.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
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ 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 com.vitorpamplona.quartz.relay.fixtures.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
|
||||||
|
|||||||
+1
-1
@@ -24,7 +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.relay.fixtures.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
|
||||||
|
|||||||
+1
-1
@@ -25,7 +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 com.vitorpamplona.quartz.relay.fixtures.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
|
||||||
|
|||||||
+1
-1
@@ -24,7 +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.relay.fixtures.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
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ rootProject.name = "Amethyst"
|
|||||||
include ':amethyst'
|
include ':amethyst'
|
||||||
include ':benchmark'
|
include ':benchmark'
|
||||||
include ':quartz'
|
include ':quartz'
|
||||||
include ':quartz-test-relay'
|
include ':quartz-relay'
|
||||||
include ':commons'
|
include ':commons'
|
||||||
include ':ammolite'
|
include ':ammolite'
|
||||||
include ':quic'
|
include ':quic'
|
||||||
|
|||||||
Reference in New Issue
Block a user