refactor(#1375): type connector operation responses (#1388)

This commit is contained in:
m57
2026-09-29 21:28:40 +03:00
committed by GitHub
parent 8ae2ddbbee
commit c8edf3665f
12 changed files with 1168 additions and 125 deletions
@@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.model.ConnectorCatalogEntry
import com.m57.hermescontrol.data.model.ConnectorError import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorPolicy import com.m57.hermescontrol.data.model.ConnectorPolicy
import com.m57.hermescontrol.data.model.ConnectorTool import com.m57.hermescontrol.data.model.ConnectorTool
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsParams import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsParams
import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsRemoveParams import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsRemoveParams
@@ -61,10 +62,9 @@ interface AccountConnectorRepository {
expectedRevision: String, expectedRevision: String,
): AccountConnectorResult<ConnectorPolicy> ): AccountConnectorResult<ConnectorPolicy>
suspend fun operationRequest( suspend fun operationRespond(params: ConnectionRespondParams): Any?
method: String,
params: Map<String, Any>, suspend fun operationWake(params: ConnectorsOperationStatusParams): Any?
): Any?
suspend fun operationStatus( suspend fun operationStatus(
opId: String, opId: String,
@@ -74,26 +74,8 @@ interface AccountConnectorRepository {
} }
class HermesAccountConnectorRepository( class HermesAccountConnectorRepository(
private val rpc: suspend (String, Map<String, Any>) -> Any? = { method, params ->
val deferred = HermesWsClient.request(method, params)
try {
deferred.await()
} catch (
e: CancellationException,
) {
deferred.cancel(e)
throw e
} finally {
if (!deferred.isCompleted) deferred.cancel()
}
},
private val caller: TypedRpcCaller = HermesRpcCaller, private val caller: TypedRpcCaller = HermesRpcCaller,
) : AccountConnectorRepository { ) : AccountConnectorRepository {
private suspend fun request(
method: String,
params: Map<String, Any> = emptyMap(),
) = rpc(method, params)
private suspend fun <T> guarded(block: suspend () -> T): AccountConnectorResult<T> = private suspend fun <T> guarded(block: suspend () -> T): AccountConnectorResult<T> =
try { try {
AccountConnectorResult.Success(block()) AccountConnectorResult.Success(block())
@@ -111,12 +93,6 @@ class HermesAccountConnectorRepository(
AccountConnectorResult.Failure(ConnectorError.NetworkError("Failed to communicate with gateway.")) AccountConnectorResult.Failure(ConnectorError.NetworkError("Failed to communicate with gateway."))
} }
private fun accountParams(params: Map<String, Any> = emptyMap()): Map<String, Any> =
buildMap {
putAll(params)
AuthManager.activeProfileId.value?.let { put("profile", it) }
}
private fun obj(value: Any?): JsonObject = ConnectorParser.toJsonElement(value).jsonObject private fun obj(value: Any?): JsonObject = ConnectorParser.toJsonElement(value).jsonObject
private fun JsonObject.str(k: String) = this[k]?.jsonPrimitive?.contentOrNull.orEmpty() private fun JsonObject.str(k: String) = this[k]?.jsonPrimitive?.contentOrNull.orEmpty()
@@ -173,10 +149,23 @@ class HermesAccountConnectorRepository(
) )
} }
override suspend fun operationRequest( override suspend fun operationRespond(params: ConnectionRespondParams): Any? =
method: String, caller.call(
params: Map<String, Any>, RpcMethods.CONNECTION_RESPOND,
): Any? = request(method, accountParams(params + ("owner" to mapOf("type" to "account")))) params.copy(
owner = ConnectorOwner.account(),
profile = AuthManager.activeProfileId.value,
),
)
override suspend fun operationWake(params: ConnectorsOperationStatusParams): Any? =
caller.call(
RpcMethods.CONNECTORS_OPERATION_WAKE,
params.copy(
owner = ConnectorOwner.account(),
profile = AuthManager.activeProfileId.value,
),
)
override suspend fun operationStatus(opId: String) = override suspend fun operationStatus(opId: String) =
guarded { guarded {
@@ -0,0 +1,26 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ConnectionAnswerTarget(
@SerialName("name") val name: String,
@SerialName("status") val status: String,
@SerialName("detail") val detail: String? = null,
@SerialName("env") val env: Map<String, String>? = null,
)
@Serializable
data class ConnectionAnswer(
@SerialName("targets") val targets: List<ConnectionAnswerTarget>? = null,
@SerialName("settled_by") val settledBy: String? = null,
)
@Serializable
data class ConnectionRespondParams(
@SerialName("owner") val owner: ConnectorOwner,
@SerialName("op_id") val opId: String,
@SerialName("result") val result: ConnectionAnswer,
@SerialName("profile") val profile: String? = null,
)
@@ -315,6 +315,22 @@ object RpcMethods {
JsonElement.serializer(), JsonElement.serializer(),
) )
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTION_RESPOND: RpcMethod<ConnectionRespondParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTION_RESPOND,
ConnectionRespondParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_OPERATION_WAKE: RpcMethod<ConnectorsOperationStatusParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_OPERATION_WAKE,
ConnectorsOperationStatusParams.serializer(),
JsonElement.serializer(),
)
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */ /** Every typed method. GatewayContractTest iterates this; future migrations append here. */
val all: List<RpcMethod<*, *>> = val all: List<RpcMethod<*, *>> =
listOf( listOf(
@@ -356,5 +372,7 @@ object RpcMethods {
CONNECTORS_POLICY_GET, CONNECTORS_POLICY_GET,
CONNECTORS_POLICY_SET, CONNECTORS_POLICY_SET,
CONNECTORS_TOOLS, CONNECTORS_TOOLS,
CONNECTION_RESPOND,
CONNECTORS_OPERATION_WAKE,
) )
} }
@@ -3,14 +3,27 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
import com.m57.hermescontrol.data.model.ConnectorError import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.ws.ConnectorParser import com.m57.hermescontrol.data.ws.ConnectorParser
import com.m57.hermescontrol.data.ws.ConnectorRepository
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswerTarget
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
sealed interface ConnectionOperationRequest {
data class Respond(
val params: ConnectionRespondParams,
) : ConnectionOperationRequest
data class Wake(
val params: ConnectorsOperationStatusParams,
) : ConnectionOperationRequest
}
sealed class ConnectionPendingAction { sealed class ConnectionPendingAction {
abstract val opId: String abstract val opId: String
abstract val observedSeq: Long abstract val observedSeq: Long
@@ -45,10 +58,7 @@ data class ConnectionOperationUiState(
) )
fun interface ConnectionOperationRequester { fun interface ConnectionOperationRequester {
suspend fun request( suspend fun request(action: ConnectionOperationRequest): Any?
method: String,
params: Map<String, Any>,
): Any?
} }
internal class ConnectionResumeCheckpoint( internal class ConnectionResumeCheckpoint(
@@ -163,19 +173,20 @@ class ChatConnectionOperationDelegate(
observedSeq = current.seq, observedSeq = current.seq,
), ),
) ?: return ) ?: return
val answer = val answerTarget =
buildMap<String, Any> { ConnectionAnswerTarget(
put("name", target) name = target,
put("status", if (approved) "approved" else "skipped") status = if (approved) "approved" else "skipped",
if (safeEnv.isNotEmpty()) put("env", safeEnv) env = safeEnv.takeIf { it.isNotEmpty() },
} )
dispatch( dispatch(
method = WsMethods.CONNECTION_RESPOND, action =
params = ConnectionOperationRequest.Respond(
mapOf( ConnectionRespondParams(
"owner" to ownerParams(), owner = owner(),
"op_id" to snapshot.opId, opId = snapshot.opId,
"result" to mapOf("targets" to listOf(answer)), result = ConnectionAnswer(targets = listOf(answerTarget)),
),
), ),
) )
} }
@@ -183,12 +194,13 @@ class ChatConnectionOperationDelegate(
suspend fun continueOperation() { suspend fun continueOperation() {
val snapshot = begin(ConnectionPendingAction.Continue(currentOp(), currentSeq())) ?: return val snapshot = begin(ConnectionPendingAction.Continue(currentOp(), currentSeq())) ?: return
dispatch( dispatch(
method = WsMethods.CONNECTION_RESPOND, action =
params = ConnectionOperationRequest.Respond(
mapOf( ConnectionRespondParams(
"owner" to ownerParams(), owner = owner(),
"op_id" to snapshot.opId, opId = snapshot.opId,
"result" to mapOf("settled_by" to "continue"), result = ConnectionAnswer(settledBy = "continue"),
),
), ),
) )
} }
@@ -198,36 +210,40 @@ class ChatConnectionOperationDelegate(
if (current.opId != expectedOpId) return if (current.opId != expectedOpId) return
val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return
dispatch( dispatch(
method = WsMethods.CONNECTORS_OPERATION_WAKE, action =
params = mapOf("owner" to ownerParams(), "op_id" to snapshot.opId), ConnectionOperationRequest.Wake(
ConnectorsOperationStatusParams(
owner = owner(),
opId = snapshot.opId,
),
),
clearOnSuccess = true, clearOnSuccess = true,
unknownOperationSettles = true, unknownOperationSettles = true,
) )
} }
private suspend fun dispatch( private suspend fun dispatch(
method: String, action: ConnectionOperationRequest,
params: Map<String, Any>,
clearOnSuccess: Boolean = false, clearOnSuccess: Boolean = false,
unknownOperationSettles: Boolean = false, unknownOperationSettles: Boolean = false,
) { ) {
val action = _state.value.pendingAction val pending = _state.value.pendingAction
val actionGeneration = generation val actionGeneration = generation
try { try {
requester.request(method, params) requester.request(action)
// Success only acknowledges receipt. Keep the exactly-once lock until // Success only acknowledges receipt. Keep the exactly-once lock until
// a newer authoritative snapshot advances the operation sequence. // a newer authoritative snapshot advances the operation sequence.
if (clearOnSuccess && generation == actionGeneration && _state.value.pendingAction == action) { if (clearOnSuccess && generation == actionGeneration && _state.value.pendingAction == pending) {
_state.value = _state.value.copy(pendingAction = null) _state.value = _state.value.copy(pendingAction = null)
} }
} catch (error: CancellationException) { } catch (error: CancellationException) {
throw error throw error
} catch (error: Exception) { } catch (error: Exception) {
if (unknownOperationSettles && error is HermesWsClient.HermesRpcException && error.code == 4004) { if (unknownOperationSettles && error is HermesWsClient.HermesRpcException && error.code == 4004) {
if (generation == actionGeneration && _state.value.pendingAction == action) { if (generation == actionGeneration && _state.value.pendingAction == pending) {
action?.opId?.let(::markSettled) pending?.opId?.let(::markSettled)
} }
} else if (generation == actionGeneration && _state.value.pendingAction == action) { } else if (generation == actionGeneration && _state.value.pendingAction == pending) {
_state.value = _state.value =
_state.value.copy( _state.value.copy(
pendingAction = null, pendingAction = null,
@@ -269,8 +285,8 @@ class ChatConnectionOperationDelegate(
snapshot.sessionId == sessionId snapshot.sessionId == sessionId
} }
private fun ownerParams(): Map<String, String> = private fun owner(): ConnectorOwner =
if (accountOwned) mapOf("type" to "account") else ConnectorRepository.sessionOwner(checkNotNull(sessionId)) if (accountOwned) ConnectorOwner.account() else ConnectorOwner.session(checkNotNull(sessionId))
private fun currentOp(): String = private fun currentOp(): String =
_state.value.operation _state.value.operation
@@ -461,8 +461,16 @@ class ChatViewModel(
constructor(application: Application) : this(application, startCleanup = true) constructor(application: Application) : this(application, startCleanup = true)
private val connectionOperationDelegate = private val connectionOperationDelegate =
ChatConnectionOperationDelegate { method, params -> ChatConnectionOperationDelegate { action ->
HermesWsClient.request(method, params).await() when (action) {
is ConnectionOperationRequest.Respond -> {
HermesWsClient.call(RpcMethods.CONNECTION_RESPOND, action.params)
}
is ConnectionOperationRequest.Wake -> {
HermesWsClient.call(RpcMethods.CONNECTORS_OPERATION_WAKE, action.params)
}
}
} }
// ── Internal state ─────────────────────────────────────────────────── // ── Internal state ───────────────────────────────────────────────────
@@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.ws.AccountConnectorRepository
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.ui.chat.ChatConnectionOperationDelegate import com.m57.hermescontrol.ui.chat.ChatConnectionOperationDelegate
import com.m57.hermescontrol.ui.chat.ConnectionOperationRequest
import com.m57.hermescontrol.ui.chat.ConnectionOperationRequester import com.m57.hermescontrol.ui.chat.ConnectionOperationRequester
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -51,7 +52,13 @@ class AccountConnectorsViewModel(
private val scopedJobs = mutableListOf<Job>() private val scopedJobs = mutableListOf<Job>()
private val operation = private val operation =
ChatConnectionOperationDelegate( ChatConnectionOperationDelegate(
requester = ConnectionOperationRequester(repository::operationRequest), requester =
ConnectionOperationRequester { action ->
when (action) {
is ConnectionOperationRequest.Respond -> repository.operationRespond(action.params)
is ConnectionOperationRequest.Wake -> repository.operationWake(action.params)
}
},
accountOwned = true, accountOwned = true,
) )
val connectionState = operation.state val connectionState = operation.state
@@ -3,6 +3,10 @@ package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.AccountConnectorResult import com.m57.hermescontrol.data.model.AccountConnectorResult
import com.m57.hermescontrol.data.model.ConnectorTool import com.m57.hermescontrol.data.model.ConnectorTool
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams
import io.mockk.every import io.mockk.every
import io.mockk.mockkObject import io.mockk.mockkObject
import io.mockk.unmockkAll import io.mockk.unmockkAll
@@ -62,14 +66,19 @@ class AccountConnectorRepositoryTest {
operationFixture() operationFixture()
} }
repo.operationStatus("op-a") repo.operationStatus("op-a")
repo.operationRequest( repo.operationRespond(
WsMethods.CONNECTION_RESPOND, ConnectionRespondParams(
mapOf( owner = ConnectorOwner.account(),
"op_id" to "op-a", opId = "op-a",
"result" to mapOf("settled_by" to "continue"), result = ConnectionAnswer(settledBy = "continue"),
),
)
repo.operationWake(
ConnectorsOperationStatusParams(
owner = ConnectorOwner.account(),
opId = "op-a",
), ),
) )
repo.operationRequest(WsMethods.CONNECTORS_OPERATION_WAKE, mapOf("op_id" to "op-a"))
assertEquals( assertEquals(
listOf( listOf(
WsMethods.CONNECTORS_OPERATION_STATUS, WsMethods.CONNECTORS_OPERATION_STATUS,
@@ -86,6 +95,23 @@ class AccountConnectorRepositoryTest {
assertEquals("op-a", params["op_id"]) assertEquals("op-a", params["op_id"])
assertFalse("session_id" in params) assertFalse("session_id" in params)
} }
assertEquals(
mapOf(
"owner" to mapOf("type" to "account"),
"op_id" to "op-a",
"result" to mapOf("settled_by" to "continue"),
"profile" to "work",
),
calls[1].second,
)
assertEquals(
mapOf(
"owner" to mapOf("type" to "account"),
"op_id" to "op-a",
"profile" to "work",
),
calls[2].second,
)
} }
@Test fun `catalog accounts tools and removal use native account endpoints`() = @Test fun `catalog accounts tools and removal use native account endpoints`() =
@@ -213,7 +239,7 @@ class AccountConnectorRepositoryTest {
} }
private fun accountRepo(handler: suspend (String, Map<String, Any>) -> Any?) = private fun accountRepo(handler: suspend (String, Map<String, Any>) -> Any?) =
HermesAccountConnectorRepository(rpc = handler, caller = fakeCaller(handler)) HermesAccountConnectorRepository(caller = fakeCaller(handler))
private fun operationFixture() = private fun operationFixture() =
mapOf( mapOf(
@@ -0,0 +1,562 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswerTarget
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
class HermesWsClientConnectionOperationTest {
private lateinit var mockWebServer: MockWebServer
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
AuthManager.resetAuthStateForTest()
}
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeoutOrNull(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
}
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
}
@Test
fun typedCallConnectionRespondWithApprovedEnvAndAccountOwnerEncodesFrameAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("status", "ok")
put("settled", true)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "connection.respond") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.CONNECTION_RESPOND,
ConnectionRespondParams(
owner = ConnectorOwner.account(),
opId = "op-approved-123",
result =
ConnectionAnswer(
targets =
listOf(
ConnectionAnswerTarget(
name = "github",
status = "approved",
detail = "OAuth granted",
env = mapOf("TOKEN" to "dummy_secret_token_abc"),
),
),
settledBy = null,
),
profile = "prod",
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "account")
},
)
put("op_id", "op-approved-123")
put(
"result",
buildJsonObject {
put(
"targets",
buildJsonArray {
add(
buildJsonObject {
put("name", "github")
put("status", "approved")
put("detail", "OAuth granted")
put(
"env",
buildJsonObject {
put("TOKEN", "dummy_secret_token_abc")
},
)
},
)
},
)
},
)
put("profile", "prod")
}
assertEquals(expectedParams, params)
assertEquals(serverResultJson, result)
}
@Test
fun typedCallConnectionRespondWithSkippedOmittedEnvAndSessionOwnerEncodesFrameAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("status", "ok")
put("settled", false)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "connection.respond") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.CONNECTION_RESPOND,
ConnectionRespondParams(
owner = ConnectorOwner.session("sess-skip-456"),
opId = "op-skip-456",
result =
ConnectionAnswer(
targets =
listOf(
ConnectionAnswerTarget(
name = "slack",
status = "skipped",
detail = null,
env = null,
),
),
settledBy = null,
),
profile = null,
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "sess-skip-456")
},
)
put("op_id", "op-skip-456")
put(
"result",
buildJsonObject {
put(
"targets",
buildJsonArray {
add(
buildJsonObject {
put("name", "slack")
put("status", "skipped")
},
)
},
)
},
)
}
assertEquals(expectedParams, params)
assertFalse(params?.containsKey("profile") ?: true)
assertEquals(serverResultJson, result)
}
@Test
fun typedCallConnectionRespondWithContinueAndOmittedTargetsEncodesFrameAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("status", "ok")
put("settled", true)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "connection.respond") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.CONNECTION_RESPOND,
ConnectionRespondParams(
owner = ConnectorOwner.session("sess-continue-789"),
opId = "op-continue-789",
result =
ConnectionAnswer(
targets = null,
settledBy = "continue",
),
profile = null,
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "sess-continue-789")
},
)
put("op_id", "op-continue-789")
put(
"result",
buildJsonObject {
put("settled_by", "continue")
},
)
}
assertEquals(expectedParams, params)
assertFalse(params?.containsKey("profile") ?: true)
assertEquals(serverResultJson, result)
}
@Test
fun typedCallConnectorsOperationWakeEncodesOwnerSessionOpIdAndProfileAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("op_id", "op-wake-777")
put("status", "running")
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "connectors.operation.wake") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.CONNECTORS_OPERATION_WAKE,
ConnectorsOperationStatusParams(
owner = ConnectorOwner.session("sess-wake-99"),
opId = "op-wake-777",
profile = "custom-profile",
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("connectors.operation.wake", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "sess-wake-99")
},
)
put("op_id", "op-wake-777")
put("profile", "custom-profile")
}
assertEquals(expectedParams, params)
assertEquals(serverResultJson, result)
}
@Test
fun hermesRpcCallerCallDelegatesForWakeWithAccountOwnerAndNullProfile() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("op_id", "op-wake-888")
put("status", "done")
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "connectors.operation.wake") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesRpcCaller.call(
RpcMethods.CONNECTORS_OPERATION_WAKE,
ConnectorsOperationStatusParams(
owner = ConnectorOwner.account(),
opId = "op-wake-888",
profile = null,
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("connectors.operation.wake", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "account")
},
)
put("op_id", "op-wake-888")
}
assertEquals(expectedParams, params)
assertFalse(params?.containsKey("profile") ?: true)
assertEquals(serverResultJson, result)
}
}
@@ -332,9 +332,6 @@ class RawRpcParamsRatchetTest {
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST", "ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST",
"ui/chat/ChatConnectionOperationDelegate.kt::CONNECTION_RESPOND",
"ui/chat/ChatConnectionOperationDelegate.kt::CONNECTION_RESPOND",
"ui/chat/ChatConnectionOperationDelegate.kt::CONNECTORS_OPERATION_WAKE",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
@@ -0,0 +1,257 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionOperationTypesTest {
@Test
fun connectionRespondParamsWithApprovedEnvAndAccountOwnerEncodesExactWireJson() {
val params =
ConnectionRespondParams(
owner = ConnectorOwner.account(),
opId = "op-approved-1",
result =
ConnectionAnswer(
targets =
listOf(
ConnectionAnswerTarget(
name = "linear",
status = "approved",
detail = "User accepted linear integration",
env = mapOf("API_KEY" to "dummy_secret_key_123"),
),
),
settledBy = null,
),
profile = "work",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "account")
},
)
put("op_id", "op-approved-1")
put(
"result",
buildJsonObject {
put(
"targets",
buildJsonArray {
add(
buildJsonObject {
put("name", "linear")
put("status", "approved")
put("detail", "User accepted linear integration")
put(
"env",
buildJsonObject {
put("API_KEY", "dummy_secret_key_123")
},
)
},
)
},
)
},
)
put("profile", "work")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "op_id", "result", "profile"), (encoded as JsonObject).keys)
val resultObj = (encoded as JsonObject)["result"] as JsonObject
assertEquals(setOf("targets"), resultObj.keys)
assertFalse(resultObj.containsKey("settled_by"))
}
@Test
fun connectionRespondParamsWithSkippedOmittedEnvAndSessionOwnerEncodesExactWireJson() {
val params =
ConnectionRespondParams(
owner = ConnectorOwner.session("sess-xyz"),
opId = "op-skip-2",
result =
ConnectionAnswer(
targets =
listOf(
ConnectionAnswerTarget(
name = "github",
status = "skipped",
detail = null,
env = null,
),
),
settledBy = null,
),
profile = null,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "sess-xyz")
},
)
put("op_id", "op-skip-2")
put(
"result",
buildJsonObject {
put(
"targets",
buildJsonArray {
add(
buildJsonObject {
put("name", "github")
put("status", "skipped")
},
)
},
)
},
)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "op_id", "result"), (encoded as JsonObject).keys)
assertFalse((encoded as JsonObject).containsKey("profile"))
val resultObj = (encoded as JsonObject)["result"] as JsonObject
assertEquals(setOf("targets"), resultObj.keys)
assertFalse(resultObj.containsKey("settled_by"))
val targetObj = (resultObj["targets"] as kotlinx.serialization.json.JsonArray)[0] as JsonObject
assertEquals(setOf("name", "status"), targetObj.keys)
assertFalse(targetObj.containsKey("env"))
assertFalse(targetObj.containsKey("detail"))
}
@Test
fun connectionRespondParamsWithContinueAndOmittedTargetsEncodesExactWireJson() {
val params =
ConnectionRespondParams(
owner = ConnectorOwner.session("sess-cont-3"),
opId = "op-cont-3",
result =
ConnectionAnswer(
targets = null,
settledBy = "continue",
),
profile = null,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "sess-cont-3")
},
)
put("op_id", "op-cont-3")
put(
"result",
buildJsonObject {
put("settled_by", "continue")
},
)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "op_id", "result"), (encoded as JsonObject).keys)
assertFalse((encoded as JsonObject).containsKey("profile"))
val resultObj = (encoded as JsonObject)["result"] as JsonObject
assertEquals(setOf("settled_by"), resultObj.keys)
assertFalse(resultObj.containsKey("targets"))
}
@Test
fun connectorsOperationWakeUsesConnectorsOperationStatusParamsAndEncodesAllFieldsWhenProfilePresent() {
val params =
ConnectorsOperationStatusParams(
owner = ConnectorOwner.session("sess-wake-1"),
opId = "op-wake-1",
profile = "staging",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "sess-wake-1")
},
)
put("op_id", "op-wake-1")
put("profile", "staging")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "op_id", "profile"), (encoded as JsonObject).keys)
}
@Test
fun connectorsOperationWakeUsesConnectorsOperationStatusParamsAndOmitsProfileWhenNull() {
val params =
ConnectorsOperationStatusParams(
owner = ConnectorOwner.account(),
opId = "op-wake-2",
profile = null,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "account")
},
)
put("op_id", "op-wake-2")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "op_id"), (encoded as JsonObject).keys)
assertFalse((encoded as JsonObject).containsKey("profile"))
}
@Test
fun connectionRespondAndWakeAreRegisteredInGatewayContractRpcMethodsAll() {
val allMethods = RpcMethods.all
val respondMethod = allMethods.firstOrNull { it.name == "connection.respond" }
val wakeMethod = allMethods.firstOrNull { it.name == "connectors.operation.wake" }
org.junit.Assert.assertNotNull("connection.respond must be registered in RpcMethods.all", respondMethod)
org.junit.Assert.assertNotNull("connectors.operation.wake must be registered in RpcMethods.all", wakeMethod)
assertEquals(RpcMethods.CONNECTION_RESPOND, respondMethod)
assertEquals(RpcMethods.CONNECTORS_OPERATION_WAKE, wakeMethod)
}
}
@@ -6,8 +6,13 @@ import com.m57.hermescontrol.data.model.ConnectionOperationTarget
import com.m57.hermescontrol.data.model.ConnectionTargetAction import com.m57.hermescontrol.data.model.ConnectionTargetAction
import com.m57.hermescontrol.data.model.ConnectionTargetKind import com.m57.hermescontrol.data.model.ConnectionTargetKind
import com.m57.hermescontrol.data.model.ConnectionTargetState import com.m57.hermescontrol.data.model.ConnectionTargetState
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswerTarget
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -15,6 +20,9 @@ import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -109,19 +117,65 @@ class ChatConnectionOperationDelegateTest {
) )
assertEquals(1, requester.calls.size) assertEquals(1, requester.calls.size)
val call = requester.calls.single() val action = requester.calls.single()
assertEquals(WsMethods.CONNECTION_RESPOND, call.first) assertTrue(action is ConnectionOperationRequest.Respond)
assertEquals(setOf("owner", "op_id", "result"), call.second.keys) val respond = action as ConnectionOperationRequest.Respond
assertEquals(mapOf("type" to "session", "session_id" to "session-a"), call.second["owner"])
assertEquals("op-a", call.second["op_id"]) assertEquals(
@Suppress("UNCHECKED_CAST") ConnectionRespondParams(
val result = call.second["result"] as Map<String, Any> owner = ConnectorOwner.session("session-a"),
opId = "op-a",
result =
ConnectionAnswer(
targets =
listOf(
ConnectionAnswerTarget(
name = "github",
status = "approved",
env = mapOf("TOKEN" to canary),
),
),
),
),
respond.params,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), respond.params)
val expectedWire =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "session-a")
},
)
put("op_id", "op-a")
put(
"result",
buildJsonObject {
put(
"targets",
buildJsonArray {
add(
buildJsonObject {
put("name", "github")
put("status", "approved")
put(
"env",
buildJsonObject {
put("TOKEN", canary)
},
)
},
)
},
)
},
)
}
assertEquals(expectedWire, encoded)
@Suppress("UNCHECKED_CAST")
val answer = (result["targets"] as List<Map<String, Any>>).single()
assertEquals("github", answer["name"])
assertEquals("approved", answer["status"])
assertEquals(mapOf("TOKEN" to canary), answer["env"])
assertFalse( assertFalse(
delegate.state.value delegate.state.value
.toString() .toString()
@@ -166,12 +220,57 @@ class ChatConnectionOperationDelegateTest {
skipDelegate.acceptRequest(snapshot()) skipDelegate.acceptRequest(snapshot())
skipDelegate.respond("github", mapOf("TOKEN" to "must-drop"), approved = false) skipDelegate.respond("github", mapOf("TOKEN" to "must-drop"), approved = false)
@Suppress("UNCHECKED_CAST") val skipAction = skipRequester.calls.single()
val skipResult = skipRequester.calls.single().second["result"] as Map<String, Any> assertTrue(skipAction is ConnectionOperationRequest.Respond)
val skipRespond = skipAction as ConnectionOperationRequest.Respond
assertEquals(
ConnectionRespondParams(
owner = ConnectorOwner.session("session-a"),
opId = "op-a",
result =
ConnectionAnswer(
targets =
listOf(
ConnectionAnswerTarget(
name = "github",
status = "skipped",
),
),
),
),
skipRespond.params,
)
@Suppress("UNCHECKED_CAST") val encodedSkip =
val skipAnswer = (skipResult["targets"] as List<Map<String, Any>>).single() OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), skipRespond.params)
assertEquals(mapOf("name" to "github", "status" to "skipped"), skipAnswer) val expectedSkipWire =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "session-a")
},
)
put("op_id", "op-a")
put(
"result",
buildJsonObject {
put(
"targets",
buildJsonArray {
add(
buildJsonObject {
put("name", "github")
put("status", "skipped")
},
)
},
)
},
)
}
assertEquals(expectedSkipWire, encodedSkip)
val continueRequester = RecordingRequester() val continueRequester = RecordingRequester()
val continueDelegate = delegate(continueRequester) val continueDelegate = delegate(continueRequester)
@@ -179,14 +278,38 @@ class ChatConnectionOperationDelegateTest {
continueDelegate.acceptRequest(snapshot()) continueDelegate.acceptRequest(snapshot())
continueDelegate.continueOperation() continueDelegate.continueOperation()
val continueAction = continueRequester.calls.single()
assertTrue(continueAction is ConnectionOperationRequest.Respond)
val continueRespond = continueAction as ConnectionOperationRequest.Respond
assertEquals( assertEquals(
mapOf( ConnectionRespondParams(
"owner" to mapOf("type" to "session", "session_id" to "session-a"), owner = ConnectorOwner.session("session-a"),
"op_id" to "op-a", opId = "op-a",
"result" to mapOf("settled_by" to "continue"), result = ConnectionAnswer(settledBy = "continue"),
), ),
continueRequester.calls.single().second, continueRespond.params,
) )
val encodedContinue =
OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), continueRespond.params)
val expectedContinueWire =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "session-a")
},
)
put("op_id", "op-a")
put(
"result",
buildJsonObject {
put("settled_by", "continue")
},
)
}
assertEquals(expectedContinueWire, encodedContinue)
} }
@Test @Test
@@ -199,15 +322,31 @@ class ChatConnectionOperationDelegateTest {
delegate.wake("op-a") delegate.wake("op-a")
val call = requester.calls.single() val action = requester.calls.single()
assertEquals(WsMethods.CONNECTORS_OPERATION_WAKE, call.first) assertTrue(action is ConnectionOperationRequest.Wake)
val wake = action as ConnectionOperationRequest.Wake
assertEquals( assertEquals(
mapOf( ConnectorsOperationStatusParams(
"owner" to mapOf("type" to "session", "session_id" to "session-a"), owner = ConnectorOwner.session("session-a"),
"op_id" to "op-a", opId = "op-a",
), ),
call.second, wake.params,
) )
val encoded =
OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), wake.params)
val expectedWire =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "session-a")
},
)
put("op_id", "op-a")
}
assertEquals(expectedWire, encoded)
} }
@Test @Test
@@ -265,7 +404,7 @@ class ChatConnectionOperationDelegateTest {
runTest { runTest {
val gate = CompletableDeferred<Unit>() val gate = CompletableDeferred<Unit>()
val requester = val requester =
ConnectionOperationRequester { _, _ -> ConnectionOperationRequester {
gate.await() gate.await()
throw IllegalStateException("old failure") throw IllegalStateException("old failure")
} }
@@ -355,13 +494,10 @@ class ChatConnectionOperationDelegateTest {
private class RecordingRequester( private class RecordingRequester(
private val failure: Throwable? = null, private val failure: Throwable? = null,
) : ConnectionOperationRequester { ) : ConnectionOperationRequester {
val calls = mutableListOf<Pair<String, Map<String, Any>>>() val calls = mutableListOf<ConnectionOperationRequest>()
override suspend fun request( override suspend fun request(action: ConnectionOperationRequest): Any? {
method: String, calls += action
params: Map<String, Any>,
): Any? {
calls += method to params
failure?.let { throw it } failure?.let { throw it }
return Unit return Unit
} }
@@ -10,7 +10,9 @@ import com.m57.hermescontrol.data.model.ConnectorPolicy
import com.m57.hermescontrol.data.ws.AccountConnectorRepository import com.m57.hermescontrol.data.ws.AccountConnectorRepository
import com.m57.hermescontrol.data.ws.ConnectionOperationParser import com.m57.hermescontrol.data.ws.ConnectionOperationParser
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
@@ -143,7 +145,7 @@ class AccountConnectorsViewModelTest {
runTest(dispatcher) { runTest(dispatcher) {
advanceUntilIdle() advanceUntilIdle()
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot()) coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
coEvery { repo.operationRequest(any(), any()) } returns mapOf("status" to "ok") coEvery { repo.operationRespond(any()) } returns mapOf("status" to "ok")
coEvery { repo.operationStatus("op-a") } returns coEvery { repo.operationStatus("op-a") } returns
AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true)) AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true))
vm.connect("drive") vm.connect("drive")
@@ -151,12 +153,11 @@ class AccountConnectorsViewModelTest {
vm.continueOperation() vm.continueOperation()
advanceUntilIdle() advanceUntilIdle()
coVerify { coVerify {
repo.operationRequest( repo.operationRespond(
WsMethods.CONNECTION_RESPOND, ConnectionRespondParams(
mapOf( owner = ConnectorOwner.account(),
"owner" to mapOf("type" to "account"), opId = "op-a",
"op_id" to "op-a", result = ConnectionAnswer(settledBy = "continue"),
"result" to mapOf("settled_by" to "continue"),
), ),
) )
} }