From c8edf3665ff1a2346625a71077a63cda0e170f2c Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:28:40 +0300 Subject: [PATCH] refactor(#1375): type connector operation responses (#1388) --- .../data/ws/AccountConnectorRepository.kt | 53 +- .../data/ws/contract/ConnectionOperation.kt | 26 + .../data/ws/contract/RpcMethod.kt | 18 + .../chat/ChatConnectionOperationDelegate.kt | 88 +-- .../hermescontrol/ui/chat/ChatViewModel.kt | 12 +- .../connectors/AccountConnectorsViewModel.kt | 9 +- .../data/ws/AccountConnectorRepositoryTest.kt | 40 +- .../HermesWsClientConnectionOperationTest.kt | 562 ++++++++++++++++++ .../data/ws/RawRpcParamsRatchetTest.kt | 3 - .../contract/ConnectionOperationTypesTest.kt | 257 ++++++++ .../ChatConnectionOperationDelegateTest.kt | 208 +++++-- .../AccountConnectorsViewModelTest.kt | 17 +- 12 files changed, 1168 insertions(+), 125 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperation.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectionOperationTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperationTypesTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt index 4c6a4042..67fd9d97 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt @@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.model.ConnectorCatalogEntry import com.m57.hermescontrol.data.model.ConnectorError import com.m57.hermescontrol.data.model.ConnectorPolicy 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.ConnectorsAccountsParams import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsRemoveParams @@ -61,10 +62,9 @@ interface AccountConnectorRepository { expectedRevision: String, ): AccountConnectorResult - suspend fun operationRequest( - method: String, - params: Map, - ): Any? + suspend fun operationRespond(params: ConnectionRespondParams): Any? + + suspend fun operationWake(params: ConnectorsOperationStatusParams): Any? suspend fun operationStatus( opId: String, @@ -74,26 +74,8 @@ interface AccountConnectorRepository { } class HermesAccountConnectorRepository( - private val rpc: suspend (String, Map) -> 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, ) : AccountConnectorRepository { - private suspend fun request( - method: String, - params: Map = emptyMap(), - ) = rpc(method, params) - private suspend fun guarded(block: suspend () -> T): AccountConnectorResult = try { AccountConnectorResult.Success(block()) @@ -111,12 +93,6 @@ class HermesAccountConnectorRepository( AccountConnectorResult.Failure(ConnectorError.NetworkError("Failed to communicate with gateway.")) } - private fun accountParams(params: Map = emptyMap()): Map = - buildMap { - putAll(params) - AuthManager.activeProfileId.value?.let { put("profile", it) } - } - private fun obj(value: Any?): JsonObject = ConnectorParser.toJsonElement(value).jsonObject private fun JsonObject.str(k: String) = this[k]?.jsonPrimitive?.contentOrNull.orEmpty() @@ -173,10 +149,23 @@ class HermesAccountConnectorRepository( ) } - override suspend fun operationRequest( - method: String, - params: Map, - ): Any? = request(method, accountParams(params + ("owner" to mapOf("type" to "account")))) + override suspend fun operationRespond(params: ConnectionRespondParams): Any? = + caller.call( + RpcMethods.CONNECTION_RESPOND, + 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) = guarded { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperation.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperation.kt new file mode 100644 index 00000000..496d9451 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperation.kt @@ -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? = null, +) + +@Serializable +data class ConnectionAnswer( + @SerialName("targets") val targets: List? = 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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index a123c18d..8b0266f3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -315,6 +315,22 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTION_RESPOND: RpcMethod = + RpcMethod( + WsMethods.CONNECTION_RESPOND, + ConnectionRespondParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_OPERATION_WAKE: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_OPERATION_WAKE, + ConnectorsOperationStatusParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -356,5 +372,7 @@ object RpcMethods { CONNECTORS_POLICY_GET, CONNECTORS_POLICY_SET, CONNECTORS_TOOLS, + CONNECTION_RESPOND, + CONNECTORS_OPERATION_WAKE, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt index 14ab2ba6..b541d561 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt @@ -3,14 +3,27 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot import com.m57.hermescontrol.data.model.ConnectorError 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.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.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow 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 { abstract val opId: String abstract val observedSeq: Long @@ -45,10 +58,7 @@ data class ConnectionOperationUiState( ) fun interface ConnectionOperationRequester { - suspend fun request( - method: String, - params: Map, - ): Any? + suspend fun request(action: ConnectionOperationRequest): Any? } internal class ConnectionResumeCheckpoint( @@ -163,19 +173,20 @@ class ChatConnectionOperationDelegate( observedSeq = current.seq, ), ) ?: return - val answer = - buildMap { - put("name", target) - put("status", if (approved) "approved" else "skipped") - if (safeEnv.isNotEmpty()) put("env", safeEnv) - } + val answerTarget = + ConnectionAnswerTarget( + name = target, + status = if (approved) "approved" else "skipped", + env = safeEnv.takeIf { it.isNotEmpty() }, + ) dispatch( - method = WsMethods.CONNECTION_RESPOND, - params = - mapOf( - "owner" to ownerParams(), - "op_id" to snapshot.opId, - "result" to mapOf("targets" to listOf(answer)), + action = + ConnectionOperationRequest.Respond( + ConnectionRespondParams( + owner = owner(), + opId = snapshot.opId, + result = ConnectionAnswer(targets = listOf(answerTarget)), + ), ), ) } @@ -183,12 +194,13 @@ class ChatConnectionOperationDelegate( suspend fun continueOperation() { val snapshot = begin(ConnectionPendingAction.Continue(currentOp(), currentSeq())) ?: return dispatch( - method = WsMethods.CONNECTION_RESPOND, - params = - mapOf( - "owner" to ownerParams(), - "op_id" to snapshot.opId, - "result" to mapOf("settled_by" to "continue"), + action = + ConnectionOperationRequest.Respond( + ConnectionRespondParams( + owner = owner(), + opId = snapshot.opId, + result = ConnectionAnswer(settledBy = "continue"), + ), ), ) } @@ -198,36 +210,40 @@ class ChatConnectionOperationDelegate( if (current.opId != expectedOpId) return val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return dispatch( - method = WsMethods.CONNECTORS_OPERATION_WAKE, - params = mapOf("owner" to ownerParams(), "op_id" to snapshot.opId), + action = + ConnectionOperationRequest.Wake( + ConnectorsOperationStatusParams( + owner = owner(), + opId = snapshot.opId, + ), + ), clearOnSuccess = true, unknownOperationSettles = true, ) } private suspend fun dispatch( - method: String, - params: Map, + action: ConnectionOperationRequest, clearOnSuccess: Boolean = false, unknownOperationSettles: Boolean = false, ) { - val action = _state.value.pendingAction + val pending = _state.value.pendingAction val actionGeneration = generation try { - requester.request(method, params) + requester.request(action) // Success only acknowledges receipt. Keep the exactly-once lock until // 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) } } catch (error: CancellationException) { throw error } catch (error: Exception) { if (unknownOperationSettles && error is HermesWsClient.HermesRpcException && error.code == 4004) { - if (generation == actionGeneration && _state.value.pendingAction == action) { - action?.opId?.let(::markSettled) + if (generation == actionGeneration && _state.value.pendingAction == pending) { + pending?.opId?.let(::markSettled) } - } else if (generation == actionGeneration && _state.value.pendingAction == action) { + } else if (generation == actionGeneration && _state.value.pendingAction == pending) { _state.value = _state.value.copy( pendingAction = null, @@ -269,8 +285,8 @@ class ChatConnectionOperationDelegate( snapshot.sessionId == sessionId } - private fun ownerParams(): Map = - if (accountOwned) mapOf("type" to "account") else ConnectorRepository.sessionOwner(checkNotNull(sessionId)) + private fun owner(): ConnectorOwner = + if (accountOwned) ConnectorOwner.account() else ConnectorOwner.session(checkNotNull(sessionId)) private fun currentOp(): String = _state.value.operation diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 87ae2a2b..f1dffe6a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -461,8 +461,16 @@ class ChatViewModel( constructor(application: Application) : this(application, startCleanup = true) private val connectionOperationDelegate = - ChatConnectionOperationDelegate { method, params -> - HermesWsClient.request(method, params).await() + ChatConnectionOperationDelegate { action -> + 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 ─────────────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt index aacfe702..aab702ee 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt @@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.ws.AccountConnectorRepository import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.ui.chat.ChatConnectionOperationDelegate +import com.m57.hermescontrol.ui.chat.ConnectionOperationRequest import com.m57.hermescontrol.ui.chat.ConnectionOperationRequester import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job @@ -51,7 +52,13 @@ class AccountConnectorsViewModel( private val scopedJobs = mutableListOf() private val operation = 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, ) val connectionState = operation.state diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt index 8db2b0f4..79654fc6 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt @@ -3,6 +3,10 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.AccountConnectorResult 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.mockkObject import io.mockk.unmockkAll @@ -62,14 +66,19 @@ class AccountConnectorRepositoryTest { operationFixture() } repo.operationStatus("op-a") - repo.operationRequest( - WsMethods.CONNECTION_RESPOND, - mapOf( - "op_id" to "op-a", - "result" to mapOf("settled_by" to "continue"), + repo.operationRespond( + ConnectionRespondParams( + owner = ConnectorOwner.account(), + opId = "op-a", + 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( listOf( WsMethods.CONNECTORS_OPERATION_STATUS, @@ -86,6 +95,23 @@ class AccountConnectorRepositoryTest { assertEquals("op-a", params["op_id"]) 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`() = @@ -213,7 +239,7 @@ class AccountConnectorRepositoryTest { } private fun accountRepo(handler: suspend (String, Map) -> Any?) = - HermesAccountConnectorRepository(rpc = handler, caller = fakeCaller(handler)) + HermesAccountConnectorRepository(caller = fakeCaller(handler)) private fun operationFixture() = mapOf( diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectionOperationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectionOperationTest.kt new file mode 100644 index 00000000..a3817d97 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectionOperationTest.kt @@ -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(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), 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().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(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(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(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(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(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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index aeee33fa..0eef3578 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -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_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", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperationTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperationTypesTest.kt new file mode 100644 index 00000000..49ea10cb --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperationTypesTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt index 2a4b1315..3d31b0ae 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt @@ -6,8 +6,13 @@ import com.m57.hermescontrol.data.model.ConnectionOperationTarget import com.m57.hermescontrol.data.model.ConnectionTargetAction import com.m57.hermescontrol.data.model.ConnectionTargetKind 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.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.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -15,6 +20,9 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest 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.assertNull @@ -109,19 +117,65 @@ class ChatConnectionOperationDelegateTest { ) assertEquals(1, requester.calls.size) - val call = requester.calls.single() - assertEquals(WsMethods.CONNECTION_RESPOND, call.first) - assertEquals(setOf("owner", "op_id", "result"), call.second.keys) - assertEquals(mapOf("type" to "session", "session_id" to "session-a"), call.second["owner"]) - assertEquals("op-a", call.second["op_id"]) - @Suppress("UNCHECKED_CAST") - val result = call.second["result"] as Map + val action = requester.calls.single() + assertTrue(action is ConnectionOperationRequest.Respond) + val respond = action as ConnectionOperationRequest.Respond + + assertEquals( + ConnectionRespondParams( + 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>).single() - assertEquals("github", answer["name"]) - assertEquals("approved", answer["status"]) - assertEquals(mapOf("TOKEN" to canary), answer["env"]) assertFalse( delegate.state.value .toString() @@ -166,12 +220,57 @@ class ChatConnectionOperationDelegateTest { skipDelegate.acceptRequest(snapshot()) skipDelegate.respond("github", mapOf("TOKEN" to "must-drop"), approved = false) - @Suppress("UNCHECKED_CAST") - val skipResult = skipRequester.calls.single().second["result"] as Map + val skipAction = skipRequester.calls.single() + 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 skipAnswer = (skipResult["targets"] as List>).single() - assertEquals(mapOf("name" to "github", "status" to "skipped"), skipAnswer) + val encodedSkip = + OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), skipRespond.params) + 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 continueDelegate = delegate(continueRequester) @@ -179,14 +278,38 @@ class ChatConnectionOperationDelegateTest { continueDelegate.acceptRequest(snapshot()) continueDelegate.continueOperation() + val continueAction = continueRequester.calls.single() + assertTrue(continueAction is ConnectionOperationRequest.Respond) + val continueRespond = continueAction as ConnectionOperationRequest.Respond assertEquals( - mapOf( - "owner" to mapOf("type" to "session", "session_id" to "session-a"), - "op_id" to "op-a", - "result" to mapOf("settled_by" to "continue"), + ConnectionRespondParams( + owner = ConnectorOwner.session("session-a"), + opId = "op-a", + 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 @@ -199,15 +322,31 @@ class ChatConnectionOperationDelegateTest { delegate.wake("op-a") - val call = requester.calls.single() - assertEquals(WsMethods.CONNECTORS_OPERATION_WAKE, call.first) + val action = requester.calls.single() + assertTrue(action is ConnectionOperationRequest.Wake) + val wake = action as ConnectionOperationRequest.Wake assertEquals( - mapOf( - "owner" to mapOf("type" to "session", "session_id" to "session-a"), - "op_id" to "op-a", + ConnectorsOperationStatusParams( + owner = ConnectorOwner.session("session-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 @@ -265,7 +404,7 @@ class ChatConnectionOperationDelegateTest { runTest { val gate = CompletableDeferred() val requester = - ConnectionOperationRequester { _, _ -> + ConnectionOperationRequester { gate.await() throw IllegalStateException("old failure") } @@ -355,13 +494,10 @@ class ChatConnectionOperationDelegateTest { private class RecordingRequester( private val failure: Throwable? = null, ) : ConnectionOperationRequester { - val calls = mutableListOf>>() + val calls = mutableListOf() - override suspend fun request( - method: String, - params: Map, - ): Any? { - calls += method to params + override suspend fun request(action: ConnectionOperationRequest): Any? { + calls += action failure?.let { throw it } return Unit } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt index e6ff4c9e..530bb7d1 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt @@ -10,7 +10,9 @@ import com.m57.hermescontrol.data.model.ConnectorPolicy import com.m57.hermescontrol.data.ws.AccountConnectorRepository import com.m57.hermescontrol.data.ws.ConnectionOperationParser 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.coVerify import io.mockk.mockk @@ -143,7 +145,7 @@ class AccountConnectorsViewModelTest { runTest(dispatcher) { advanceUntilIdle() 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 AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true)) vm.connect("drive") @@ -151,12 +153,11 @@ class AccountConnectorsViewModelTest { vm.continueOperation() advanceUntilIdle() coVerify { - repo.operationRequest( - WsMethods.CONNECTION_RESPOND, - mapOf( - "owner" to mapOf("type" to "account"), - "op_id" to "op-a", - "result" to mapOf("settled_by" to "continue"), + repo.operationRespond( + ConnectionRespondParams( + owner = ConnectorOwner.account(), + opId = "op-a", + result = ConnectionAnswer(settledBy = "continue"), ), ) }