diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 28f780ce5..08f9feda3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -81,6 +81,7 @@ kotlinTest = "2.3.21" core = "1.7.0" mavenPublish = "0.36.0" sqlite = "2.6.2" +ktor = "3.4.1" [libraries] 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" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", 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-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" } diff --git a/quartz-test-relay/build.gradle.kts b/quartz-relay/build.gradle.kts similarity index 56% rename from quartz-test-relay/build.gradle.kts rename to quartz-relay/build.gradle.kts index 3c87e3f99..5c03df463 100644 --- a/quartz-test-relay/build.gradle.kts +++ b/quartz-relay/build.gradle.kts @@ -2,6 +2,12 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.jetbrainsKotlinJvm) + application +} + +application { + mainClass.set("com.vitorpamplona.quartz.relay.MainKt") + applicationName = "quartz-relay" } kotlin { @@ -26,10 +32,18 @@ dependencies { implementation(libs.kotlinx.coroutines.core) 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) + // 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.kotlinx.coroutines.test) testImplementation(libs.secp256k1.kmp.jni.jvm) + testImplementation(libs.okhttp) } diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt similarity index 94% rename from quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt rename to quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt index b487ce46f..a54560089 100644 --- a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt @@ -18,7 +18,7 @@ * 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 +package com.vitorpamplona.quartz.relay import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket @@ -32,7 +32,7 @@ import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED 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 * [connect] and routes: * @@ -42,7 +42,7 @@ import kotlinx.coroutines.launch * - Server-side `send` callbacks → [WebSocketListener.onMessage]. */ class InProcessWebSocket( - private val relay: TestRelay, + private val relay: Relay, private val out: WebSocketListener, ) : WebSocket { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt new file mode 100644 index 000000000..ca4d398d5 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt @@ -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 + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt new file mode 100644 index 000000000..d9908dd4d --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt @@ -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 bind address (default 0.0.0.0) + * --port tcp port (default 7447, 0 to autobind) + * --path

ws path (default /) + * --info NIP-11 doc file (default: built-in) + * --db 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) { + 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, + private val flags: Set, +) { + fun opt(k: String) = opts[k] + + fun flag(k: String) = k in flags +} + +private fun parseArgs(args: Array): Args { + val opts = mutableMapOf() + val flags = mutableSetOf() + 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) +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt similarity index 78% rename from quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt rename to quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt index ccd251457..9df98315c 100644 --- a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt @@ -18,7 +18,7 @@ * 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 +package com.vitorpamplona.quartz.relay import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper @@ -33,15 +33,25 @@ 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. + * A self-contained Nostr relay scoped to a single URL. Wraps a [NostrServer] + * over an [EventStore] (defaults to an in-memory SQLite database). * - * Use [TestRelayHub] to register relays under URLs the production - * [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] can subscribe to. + * Speaks NIP-01 (REQ/EVENT/EOSE/CLOSE), NIP-11 (relay info via [info]), + * 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 store: IEventStore = EventStore(dbName = null, relay = url), + val info: RelayInfo = RelayInfo.default(url), policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, parentContext: CoroutineContext = SupervisorJob(), ) : AutoCloseable { diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt similarity index 83% rename from quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt rename to quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt index 45a3fab10..6f967f28d 100644 --- a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt @@ -18,7 +18,7 @@ * 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 +package com.vitorpamplona.quartz.relay import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl 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 /** - * 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 * [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] in place of * `BasicOkHttpWebSocket.Builder` to redirect every outbound connection to an @@ -38,7 +38,7 @@ import java.util.concurrent.ConcurrentHashMap * * Usage: * ``` - * val hub = TestRelayHub() + * val hub = RelayHub() * val relay = hub.getOrCreate("ws://test.relay/") * runBlocking { relay.preload(listOf(event1, event2)) } * 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 * back any number of test endpoints. */ -class TestRelayHub( +class RelayHub( private val defaultPolicy: () -> IRelayPolicy = { EmptyPolicy }, ) : WebsocketBuilder, AutoCloseable { - private val relays = ConcurrentHashMap() + private val relays = ConcurrentHashMap() - fun getOrCreate(url: NormalizedRelayUrl): TestRelay = + fun getOrCreate(url: NormalizedRelayUrl): Relay = 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 = relays.keys.toSet() diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt new file mode 100644 index 000000000..7e49e6cfe --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt @@ -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)) + } +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/RelayFixtures.kt similarity index 98% rename from quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt rename to quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/RelayFixtures.kt index 6fc510385..2ff341ea1 100644 --- a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/RelayFixtures.kt @@ -18,7 +18,7 @@ * 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 +package com.vitorpamplona.quartz.relay.fixtures import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/SyntheticEvents.kt similarity index 98% rename from quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt rename to quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/SyntheticEvents.kt index 640dfa4b1..831c73c3e 100644 --- a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/SyntheticEvents.kt @@ -18,7 +18,7 @@ * 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 +package com.vitorpamplona.quartz.relay.fixtures import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt new file mode 100644 index 000000000..a55393b32 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt @@ -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() + } + } +} diff --git a/quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt similarity index 98% rename from quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt rename to quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt index 2fc3487f6..3cf9768ae 100644 --- a/quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt @@ -18,7 +18,7 @@ * 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 +package com.vitorpamplona.quartz.relay import com.vitorpamplona.quartz.nip01Core.core.Event 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.signers.NostrSignerSync import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -62,7 +63,7 @@ import kotlin.test.assertTrue * `socketBuilder` and the relay URL would change. */ class Nip01ComplianceTest { - private lateinit var hub: TestRelayHub + private lateinit var hub: RelayHub private lateinit var scope: CoroutineScope private lateinit var client: NostrClient @@ -70,7 +71,7 @@ class Nip01ComplianceTest { @BeforeTest fun setup() { - hub = TestRelayHub() + hub = RelayHub() scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) client = NostrClient(hub, scope) } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index a6a6fc9a9..723a757f6 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -182,8 +182,10 @@ kotlin { 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")) + // need network access or a Rust toolchain. The + // `relay.fixtures` package carries the test-only event + // generators and corpus loader. + implementation(project(":quartz-relay")) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt index 6c1d0cbfa..e8133fe08 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt @@ -20,11 +20,11 @@ */ 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 - * 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 * network or relying on production relays. * @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.testrelay.TestRelayHub * the specific test that needs it. */ open class BaseNostrClientTest { - val relayHub: TestRelayHub = TestRelayHub() + val relayHub: RelayHub = RelayHub() /** Plug into `NostrClient(socketBuilder, scope)`. */ val socketBuilder get() = relayHub diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index ad5ac6123..b5e0cfcce 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -26,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.normalizer.NormalizedRelayUrl 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.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt index d7f9c236b..0b42a6066 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt @@ -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.filters.Filter 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.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index 25f1b93e5..a63a3e512 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -29,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.normalizer.RelayUrlNormalizer 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt index 4e3c639c2..6f413e5b6 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt @@ -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.filters.Filter 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.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt index 30e670ff7..90538ddf8 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt @@ -24,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.reqs.subscribeAsFlow 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt index 141bf6cdf..3c8c10bff 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt @@ -25,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.filters.Filter 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.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt index e3de606f2..752d8d21a 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt @@ -24,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.reqs.fetchAsFlow 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/settings.gradle b/settings.gradle index 7ca462562..74317ce69 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,7 +34,7 @@ rootProject.name = "Amethyst" include ':amethyst' include ':benchmark' include ':quartz' -include ':quartz-test-relay' +include ':quartz-relay' include ':commons' include ':ammolite' include ':quic'