feat(nestsClient): NestsListener facade + connect orchestrator
Phase 3d-2 of the Clubhouse/nests integration. Ties HTTP auth (3a) +
WebTransport (3b-1 stub) + MoQ session (3c) under a single
`connectNestsListener()` entry point with an observable state
machine, so audio-room callers see one resource and one StateFlow
instead of three layered handshakes. Pure commonMain — fully tested
against fakes; no network or Android needed.
commonMain (nestsclient):
- `NestsListener` interface — `state: StateFlow<NestsListenerState>`,
`subscribeSpeaker(pubkeyHex)`, `close()`. nests namespaces each
speaker's track as `["nests", <roomId>]` with the speaker's pubkey
hex as track name; subscribeSpeaker fills that in.
- `NestsListenerState` sealed class:
* Idle
* Connecting(step = ResolvingRoom | OpeningTransport | MoqHandshake)
* Connected(roomInfo, negotiatedMoqVersion)
* Failed(reason, cause?)
* Closed
- `DefaultNestsListener` — straight delegation to MoqSession once
connected.
- `connectNestsListener(httpClient, transport, scope, serviceBase,
roomId, signer, supportedMoqVersions)` — walks the three handshake
steps. Each failure short-circuits to Failed with a clear reason
string and the underlying cause attached; transport is torn down on
partial-handshake failure.
- `parseEndpoint(url)` — hand-rolled URL splitter so commonMain stays
free of a URL-library dependency. Handles default-port stripping,
rejects userinfo, preserves query strings.
commonTest:
- `NestsConnectTest` (5 cases, all using fakes):
* Happy path: walks resolveRoom -> transport.connect -> MoQ SETUP,
asserts state lands on Connected with the right roomInfo +
negotiated version, and verifies the transport saw the parsed
authority/path/bearer.
* resolveRoom failure short-circuits without touching transport.
* Transport handshake failure short-circuits with the right kind in
the reason.
* Malformed endpoint URL short-circuits.
* parseEndpoint covers default-port stripping, explicit port,
pathless URL, query string preservation.
This completes the pure-Kotlin integration. The only seam left
between `connectNestsListener()` and audible audio is the Kwik-backed
WebTransportFactory implementation (Phase 3b-2). When that lands, an
Amethyst AudioRoomViewModel can wire the existing AudioRoomStage to:
state = MutableStateFlow(NestsListenerState.Idle)
listener = connectNestsListener(...)
for each speaker p: AudioRoomPlayer(MediaCodecOpusDecoder(),
AudioTrackPlayer(), scope).play(listener.subscribeSpeaker(p).objects)
…and audio plays.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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.nestsclient
|
||||
|
||||
import com.vitorpamplona.nestsclient.moq.MoqSession
|
||||
import com.vitorpamplona.nestsclient.moq.MoqVersion
|
||||
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
|
||||
import com.vitorpamplona.nestsclient.moq.TrackNamespace
|
||||
import com.vitorpamplona.nestsclient.transport.WebTransportException
|
||||
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Walk the full join-as-listener handshake against a nests-compatible audio
|
||||
* server:
|
||||
*
|
||||
* 1. Resolve the room — POST/GET `<serviceBase>/<roomId>` with NIP-98 auth,
|
||||
* returning [NestsRoomInfo] (the MoQ endpoint + bearer token).
|
||||
* 2. Open a [com.vitorpamplona.nestsclient.transport.WebTransportSession]
|
||||
* against the endpoint via [transport].
|
||||
* 3. Run the MoQ SETUP handshake.
|
||||
*
|
||||
* The returned [NestsListener] is in state [NestsListenerState.Connected];
|
||||
* if any step fails, the listener is returned in
|
||||
* [NestsListenerState.Failed] with the underlying cause attached and the
|
||||
* transport torn down.
|
||||
*
|
||||
* @param signer NIP-98 signer for the resolveRoom HTTP call.
|
||||
* @param scope where the [MoqSession] pumps live (typically the caller's
|
||||
* ViewModel scope so they cancel when the screen leaves).
|
||||
* @param supportedMoqVersions in preference order; defaults to draft-17.
|
||||
*/
|
||||
suspend fun connectNestsListener(
|
||||
httpClient: NestsClient,
|
||||
transport: WebTransportFactory,
|
||||
scope: CoroutineScope,
|
||||
serviceBase: String,
|
||||
roomId: String,
|
||||
signer: NostrSigner,
|
||||
supportedMoqVersions: List<Long> = listOf(MoqVersion.DRAFT_17),
|
||||
): NestsListener {
|
||||
val state =
|
||||
MutableStateFlow<NestsListenerState>(
|
||||
NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.ResolvingRoom),
|
||||
)
|
||||
|
||||
val roomInfo =
|
||||
try {
|
||||
httpClient.resolveRoom(serviceBase = serviceBase, roomId = roomId, signer = signer)
|
||||
} catch (t: NestsException) {
|
||||
state.value = NestsListenerState.Failed("Room resolution failed: ${t.message}", t)
|
||||
return failedListener(state)
|
||||
}
|
||||
|
||||
state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.OpeningTransport)
|
||||
|
||||
val (authority, path) =
|
||||
try {
|
||||
parseEndpoint(roomInfo.endpoint)
|
||||
} catch (t: Throwable) {
|
||||
state.value =
|
||||
NestsListenerState.Failed(
|
||||
"Malformed MoQ endpoint URL '${roomInfo.endpoint}': ${t.message}",
|
||||
t,
|
||||
)
|
||||
return failedListener(state)
|
||||
}
|
||||
|
||||
val webTransport =
|
||||
try {
|
||||
transport.connect(authority = authority, path = path, bearerToken = roomInfo.token)
|
||||
} catch (t: WebTransportException) {
|
||||
state.value =
|
||||
NestsListenerState.Failed(
|
||||
"WebTransport ${t.kind.name}: ${t.message}",
|
||||
t,
|
||||
)
|
||||
return failedListener(state)
|
||||
}
|
||||
|
||||
state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.MoqHandshake)
|
||||
|
||||
val moq =
|
||||
try {
|
||||
MoqSession.client(webTransport, scope).also { it.setup(supportedMoqVersions) }
|
||||
} catch (t: Throwable) {
|
||||
runCatching { webTransport.close(0, "moq setup failed") }
|
||||
state.value = NestsListenerState.Failed("MoQ handshake failed: ${t.message}", t)
|
||||
return failedListener(state)
|
||||
}
|
||||
|
||||
val negotiatedVersion =
|
||||
moq.selectedVersion ?: run {
|
||||
runCatching { moq.close() }
|
||||
state.value = NestsListenerState.Failed("MoQ session reported no negotiated version")
|
||||
return failedListener(state)
|
||||
}
|
||||
|
||||
state.value = NestsListenerState.Connected(roomInfo, negotiatedVersion)
|
||||
return DefaultNestsListener(
|
||||
session = moq,
|
||||
roomNamespace = TrackNamespace.of("nests", roomId),
|
||||
mutableState = state,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a no-op [NestsListener] in a Failed state for callers that want a
|
||||
* uniform return type even on early-failure paths.
|
||||
*/
|
||||
private fun failedListener(state: MutableStateFlow<NestsListenerState>): NestsListener =
|
||||
object : NestsListener {
|
||||
override val state = state
|
||||
|
||||
override suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle = error("listener never connected: ${state.value}")
|
||||
|
||||
override suspend fun close() {
|
||||
if (state.value !is NestsListenerState.Closed) {
|
||||
state.value = NestsListenerState.Closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a typical nests endpoint URL such as `https://relay.example.com/moq`
|
||||
* or `https://relay.example.com:4443/api/v1/moq?room=abc` into the
|
||||
* WebTransport `(authority, path)` pair. WebTransport authority is
|
||||
* `host[:port]` (port omitted when it's the protocol default); path is
|
||||
* everything after the authority including any query string. Defaults to
|
||||
* `/` when the URL has no path.
|
||||
*
|
||||
* Hand-rolled rather than pulling in a URL library so this stays in
|
||||
* commonMain with no extra dependency.
|
||||
*/
|
||||
internal fun parseEndpoint(endpoint: String): Pair<String, String> {
|
||||
val schemeEnd = endpoint.indexOf("://")
|
||||
require(schemeEnd > 0) { "endpoint must include a scheme (got '$endpoint')" }
|
||||
val scheme = endpoint.substring(0, schemeEnd).lowercase()
|
||||
val rest = endpoint.substring(schemeEnd + 3)
|
||||
|
||||
val pathSep = rest.indexOf('/')
|
||||
val (authorityRaw, pathRaw) =
|
||||
if (pathSep < 0) {
|
||||
rest to "/"
|
||||
} else {
|
||||
rest.substring(0, pathSep) to rest.substring(pathSep)
|
||||
}
|
||||
|
||||
require(authorityRaw.isNotEmpty()) { "endpoint must include an authority (got '$endpoint')" }
|
||||
|
||||
val portSep = authorityRaw.lastIndexOf(':')
|
||||
val hasUserInfo = authorityRaw.contains('@')
|
||||
require(!hasUserInfo) { "endpoint must not include userinfo (got '$endpoint')" }
|
||||
|
||||
// Strip the port if it's the scheme default so the on-the-wire authority is
|
||||
// canonical.
|
||||
val authority =
|
||||
if (portSep >= 0) {
|
||||
val host = authorityRaw.substring(0, portSep)
|
||||
val portStr = authorityRaw.substring(portSep + 1)
|
||||
val port = portStr.toIntOrNull() ?: error("malformed port '$portStr' in '$endpoint'")
|
||||
val defaultPort =
|
||||
when (scheme) {
|
||||
"https", "wss" -> 443
|
||||
"http", "ws" -> 80
|
||||
else -> -1
|
||||
}
|
||||
if (port == defaultPort) host else "$host:$port"
|
||||
} else {
|
||||
authorityRaw
|
||||
}
|
||||
|
||||
return authority to pathRaw
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* 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.nestsclient
|
||||
|
||||
import com.vitorpamplona.nestsclient.moq.MoqSession
|
||||
import com.vitorpamplona.nestsclient.moq.SubscribeFilter
|
||||
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
|
||||
import com.vitorpamplona.nestsclient.moq.TrackNamespace
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* High-level listener handle for an audio-room. Hides the layered HTTP +
|
||||
* WebTransport + MoQ wiring under one observable state machine so UI code
|
||||
* (and tests) can reason about the room as a single resource.
|
||||
*
|
||||
* Open one [NestsListener] per audio-room screen. To listen to multiple
|
||||
* speakers in the room, call [subscribeSpeaker] once per speaker pubkey.
|
||||
*/
|
||||
interface NestsListener {
|
||||
/** Live connection state — the UI typically shows a chip / spinner derived from this. */
|
||||
val state: StateFlow<NestsListenerState>
|
||||
|
||||
/**
|
||||
* Subscribe to one speaker's audio track. nests publishes each speaker's
|
||||
* Opus stream under the namespace `["nests", <roomId>]` with the
|
||||
* speaker's pubkey hex as the track name.
|
||||
*
|
||||
* @throws com.vitorpamplona.nestsclient.moq.MoqProtocolException if the
|
||||
* publisher rejects the subscription.
|
||||
* @throws IllegalStateException if the listener is not in [NestsListenerState.Connected].
|
||||
*/
|
||||
suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle
|
||||
|
||||
/** Tear down the MoQ session + underlying transport. Idempotent. */
|
||||
suspend fun close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle states of a [NestsListener].
|
||||
*
|
||||
* Resolved/connected information (room metadata, MoQ version) is carried on
|
||||
* [NestsListenerState.Connected] so the UI can render details without
|
||||
* threading them separately.
|
||||
*/
|
||||
sealed class NestsListenerState {
|
||||
/** No connection attempt has started yet. */
|
||||
data object Idle : NestsListenerState()
|
||||
|
||||
/** A connect call is in flight. [step] indicates which substage. */
|
||||
data class Connecting(
|
||||
val step: ConnectStep,
|
||||
) : NestsListenerState() {
|
||||
enum class ConnectStep {
|
||||
/** Calling `<service>/<roomId>` to obtain the MoQ endpoint + token. */
|
||||
ResolvingRoom,
|
||||
|
||||
/** Opening the WebTransport (Kwik QUIC + Extended CONNECT). */
|
||||
OpeningTransport,
|
||||
|
||||
/** Running the MoQ CLIENT_SETUP / SERVER_SETUP exchange. */
|
||||
MoqHandshake,
|
||||
}
|
||||
}
|
||||
|
||||
/** Connection is live. [roomInfo] reflects the resolved server metadata. */
|
||||
data class Connected(
|
||||
val roomInfo: NestsRoomInfo,
|
||||
val negotiatedMoqVersion: Long,
|
||||
) : NestsListenerState()
|
||||
|
||||
/** A connect attempt or live session failed. UI shows [reason] to the user. */
|
||||
data class Failed(
|
||||
val reason: String,
|
||||
val cause: Throwable? = null,
|
||||
) : NestsListenerState()
|
||||
|
||||
/** [close] has been called. Terminal. */
|
||||
data object Closed : NestsListenerState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Default [NestsListener] implementation that delegates to a [MoqSession]
|
||||
* already set up on a [com.vitorpamplona.nestsclient.transport.WebTransportSession].
|
||||
*
|
||||
* Construction does NOT open the transport — call [connectNestsListener] to
|
||||
* walk the full HTTP + transport + MoQ handshake; this class just owns the
|
||||
* resulting session and exposes the listener API on top.
|
||||
*/
|
||||
class DefaultNestsListener internal constructor(
|
||||
private val session: MoqSession,
|
||||
private val roomNamespace: TrackNamespace,
|
||||
private val mutableState: MutableStateFlow<NestsListenerState>,
|
||||
) : NestsListener {
|
||||
override val state: StateFlow<NestsListenerState> = mutableState.asStateFlow()
|
||||
|
||||
override suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle {
|
||||
check(state.value is NestsListenerState.Connected) {
|
||||
"NestsListener.subscribeSpeaker requires Connected state, was ${state.value}"
|
||||
}
|
||||
return session.subscribe(
|
||||
namespace = roomNamespace,
|
||||
trackName = speakerPubkeyHex.encodeToByteArray(),
|
||||
filter = SubscribeFilter.LatestGroup,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
if (state.value is NestsListenerState.Closed) return
|
||||
runCatching { session.close() }
|
||||
mutableState.value = NestsListenerState.Closed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* 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.nestsclient
|
||||
|
||||
import com.vitorpamplona.nestsclient.moq.ClientSetup
|
||||
import com.vitorpamplona.nestsclient.moq.MoqCodec
|
||||
import com.vitorpamplona.nestsclient.moq.MoqVersion
|
||||
import com.vitorpamplona.nestsclient.moq.ServerSetup
|
||||
import com.vitorpamplona.nestsclient.transport.FakeWebTransport
|
||||
import com.vitorpamplona.nestsclient.transport.WebTransportException
|
||||
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
|
||||
import com.vitorpamplona.nestsclient.transport.WebTransportSession
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
class NestsConnectTest {
|
||||
@Test
|
||||
fun connect_walks_resolveRoom_then_transport_then_moq_handshake() =
|
||||
runTest {
|
||||
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||
val httpClient =
|
||||
FakeNestsClient(
|
||||
NestsRoomInfo(
|
||||
endpoint = "https://relay.example.com/moq",
|
||||
token = "tok-abc",
|
||||
),
|
||||
)
|
||||
val transport = ConstantWebTransportFactory(clientSide)
|
||||
|
||||
// Server-side raw peer answers SETUP.
|
||||
val server =
|
||||
async {
|
||||
val control = serverSide.peerOpenedBidiStreams().first()
|
||||
val cs = MoqCodec.decode(control.incoming().first())!!.message as ClientSetup
|
||||
control.write(MoqCodec.encode(ServerSetup(cs.supportedVersions.first())))
|
||||
}
|
||||
|
||||
val listener =
|
||||
connectNestsListener(
|
||||
httpClient = httpClient,
|
||||
transport = transport,
|
||||
scope = this,
|
||||
serviceBase = "https://relay.example.com/api/v1/nests",
|
||||
roomId = "abc",
|
||||
signer = NostrSignerInternal(KeyPair()),
|
||||
)
|
||||
server.await()
|
||||
|
||||
val connected = assertIs<NestsListenerState.Connected>(listener.state.value)
|
||||
assertEquals("https://relay.example.com/moq", connected.roomInfo.endpoint)
|
||||
assertEquals(MoqVersion.DRAFT_17, connected.negotiatedMoqVersion)
|
||||
|
||||
assertEquals("relay.example.com", transport.lastConnectedAuthority)
|
||||
assertEquals("/moq", transport.lastConnectedPath)
|
||||
assertEquals("tok-abc", transport.lastBearer)
|
||||
|
||||
listener.close()
|
||||
assertIs<NestsListenerState.Closed>(listener.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveRoom_failure_short_circuits_to_Failed() =
|
||||
runTest {
|
||||
val httpClient =
|
||||
ThrowingNestsClient(
|
||||
NestsException("server returned 500", status = 500),
|
||||
)
|
||||
val transport = NeverConnectFactory()
|
||||
|
||||
val listener =
|
||||
connectNestsListener(
|
||||
httpClient = httpClient,
|
||||
transport = transport,
|
||||
scope = this,
|
||||
serviceBase = "https://relay.example.com/api/v1/nests",
|
||||
roomId = "abc",
|
||||
signer = NostrSignerInternal(KeyPair()),
|
||||
)
|
||||
|
||||
val failed = assertIs<NestsListenerState.Failed>(listener.state.value)
|
||||
assertTrue("Room resolution failed" in failed.reason)
|
||||
assertTrue("500" in failed.reason || (failed.cause as? NestsException)?.status == 500)
|
||||
assertEquals(0, transport.connectCallCount, "transport must not be reached")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transport_handshake_failure_short_circuits_to_Failed() =
|
||||
runTest {
|
||||
val httpClient =
|
||||
FakeNestsClient(NestsRoomInfo(endpoint = "https://relay.example.com/moq"))
|
||||
val transport =
|
||||
ThrowingTransportFactory(
|
||||
WebTransportException(
|
||||
WebTransportException.Kind.HandshakeFailed,
|
||||
"QUIC handshake refused",
|
||||
),
|
||||
)
|
||||
|
||||
val listener =
|
||||
connectNestsListener(
|
||||
httpClient = httpClient,
|
||||
transport = transport,
|
||||
scope = this,
|
||||
serviceBase = "https://relay.example.com/api/v1/nests",
|
||||
roomId = "abc",
|
||||
signer = NostrSignerInternal(KeyPair()),
|
||||
)
|
||||
|
||||
val failed = assertIs<NestsListenerState.Failed>(listener.state.value)
|
||||
assertTrue("HandshakeFailed" in failed.reason, "got: ${failed.reason}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformed_endpoint_url_short_circuits_to_Failed() =
|
||||
runTest {
|
||||
val httpClient =
|
||||
FakeNestsClient(NestsRoomInfo(endpoint = "not-a-url"))
|
||||
|
||||
val listener =
|
||||
connectNestsListener(
|
||||
httpClient = httpClient,
|
||||
transport = NeverConnectFactory(),
|
||||
scope = this,
|
||||
serviceBase = "https://relay.example.com/api/v1/nests",
|
||||
roomId = "abc",
|
||||
signer = NostrSignerInternal(KeyPair()),
|
||||
)
|
||||
|
||||
val failed = assertIs<NestsListenerState.Failed>(listener.state.value)
|
||||
assertTrue("Malformed MoQ endpoint" in failed.reason, "got: ${failed.reason}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseEndpoint_drops_default_port_keeps_explicit_port_and_path() {
|
||||
assertEquals(
|
||||
"relay.example.com" to "/moq",
|
||||
parseEndpoint("https://relay.example.com/moq"),
|
||||
)
|
||||
assertEquals(
|
||||
"relay.example.com:4443" to "/moq",
|
||||
parseEndpoint("https://relay.example.com:4443/moq"),
|
||||
)
|
||||
assertEquals(
|
||||
"relay.example.com" to "/",
|
||||
parseEndpoint("https://relay.example.com"),
|
||||
)
|
||||
assertEquals(
|
||||
"relay.example.com" to "/api/v1/moq?room=abc",
|
||||
parseEndpoint("https://relay.example.com/api/v1/moq?room=abc"),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- fakes
|
||||
|
||||
private class FakeNestsClient(
|
||||
private val info: NestsRoomInfo,
|
||||
) : NestsClient {
|
||||
override suspend fun resolveRoom(
|
||||
serviceBase: String,
|
||||
roomId: String,
|
||||
signer: NostrSigner,
|
||||
): NestsRoomInfo = info
|
||||
}
|
||||
|
||||
private class ThrowingNestsClient(
|
||||
private val toThrow: NestsException,
|
||||
) : NestsClient {
|
||||
override suspend fun resolveRoom(
|
||||
serviceBase: String,
|
||||
roomId: String,
|
||||
signer: NostrSigner,
|
||||
): NestsRoomInfo = throw toThrow
|
||||
}
|
||||
|
||||
private class ConstantWebTransportFactory(
|
||||
private val session: WebTransportSession,
|
||||
) : WebTransportFactory {
|
||||
var lastConnectedAuthority: String? = null
|
||||
private set
|
||||
var lastConnectedPath: String? = null
|
||||
private set
|
||||
var lastBearer: String? = null
|
||||
private set
|
||||
|
||||
override suspend fun connect(
|
||||
authority: String,
|
||||
path: String,
|
||||
bearerToken: String?,
|
||||
): WebTransportSession {
|
||||
lastConnectedAuthority = authority
|
||||
lastConnectedPath = path
|
||||
lastBearer = bearerToken
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
private class ThrowingTransportFactory(
|
||||
private val toThrow: WebTransportException,
|
||||
) : WebTransportFactory {
|
||||
override suspend fun connect(
|
||||
authority: String,
|
||||
path: String,
|
||||
bearerToken: String?,
|
||||
): WebTransportSession = throw toThrow
|
||||
}
|
||||
|
||||
private class NeverConnectFactory : WebTransportFactory {
|
||||
var connectCallCount = 0
|
||||
private set
|
||||
|
||||
override suspend fun connect(
|
||||
authority: String,
|
||||
path: String,
|
||||
bearerToken: String?,
|
||||
): WebTransportSession {
|
||||
connectCallCount++
|
||||
fail("transport.connect must not be called")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user