From 8ae2ddbbeed27b51fa27cd711a2c546233d90657 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:27:27 +0300 Subject: [PATCH] refactor(#1375): type connectors repository RPC params (#1387) --- .../data/ws/AccountConnectorRepository.kt | 115 ++-- .../data/ws/ConnectorRepository.kt | 34 +- .../data/ws/contract/Connectors.kt | 74 +++ .../data/ws/contract/RpcMethod.kt | 96 ++++ .../data/ws/AccountConnectorRepositoryTest.kt | 15 +- .../data/ws/ConnectorRepositoryTest.kt | 171 +++--- .../data/ws/FakeTypedRpcCaller.kt | 22 + .../data/ws/HermesWsClientConnectorsTest.kt | 358 ++++++++++++ .../data/ws/RawRpcParamsRatchetTest.kt | 11 - .../data/ws/contract/ConnectorsTypesTest.kt | 539 ++++++++++++++++++ 10 files changed, 1286 insertions(+), 149 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/Connectors.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/FakeTypedRpcCaller.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectorsTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectorsTypesTest.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 9a5fee1d..4c6a4042 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,19 @@ 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.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsRemoveParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsCatalogParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicyGetParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicySetParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsToolsParams +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -74,6 +87,7 @@ class HermesAccountConnectorRepository( if (!deferred.isCompleted) deferred.cancel() } }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) : AccountConnectorRepository { private suspend fun request( method: String, @@ -116,10 +130,11 @@ class HermesAccountConnectorRepository( override suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult = try { ConnectorParser.parseListResult( - rpc( - WsMethods.CONNECTORS_LIST, - accountParams( - mapOf("owner" to mapOf("type" to "account")), + caller.call( + RpcMethods.CONNECTORS_LIST, + ConnectorsListParams( + owner = ConnectorOwner.account(), + profile = AuthManager.activeProfileId.value, ), ), ) @@ -146,14 +161,13 @@ class HermesAccountConnectorRepository( ) = guarded { require(slugs.isNotEmpty() && slugs.all(ConnectorRepository::isValidSlug)) parseOperation( - request( - WsMethods.CONNECTORS_CONNECT, - accountParams( - mapOf( - "owner" to mapOf("type" to "account"), - "connectors" to slugs, - "reconnect" to reconnect, - ), + caller.call( + RpcMethods.CONNECTORS_CONNECT, + ConnectorsConnectParams( + owner = ConnectorOwner.account(), + connectors = slugs, + reconnect = reconnect, + profile = AuthManager.activeProfileId.value, ), ), ) @@ -166,7 +180,16 @@ class HermesAccountConnectorRepository( override suspend fun operationStatus(opId: String) = guarded { - parseOperation(operationRequest(WsMethods.CONNECTORS_OPERATION_STATUS, mapOf("op_id" to opId))) + parseOperation( + caller.call( + RpcMethods.CONNECTORS_OPERATION_STATUS, + ConnectorsOperationStatusParams( + owner = ConnectorOwner.account(), + opId = opId, + profile = AuthManager.activeProfileId.value, + ), + ), + ) } @Suppress("UNCHECKED_CAST") @@ -180,7 +203,14 @@ class HermesAccountConnectorRepository( override suspend fun catalog() = guarded { - obj(request(WsMethods.CONNECTORS_CATALOG, accountParams()))["connectors"]!!.jsonArray.map { + val response = + caller.call( + RpcMethods.CONNECTORS_CATALOG, + ConnectorsCatalogParams( + profile = AuthManager.activeProfileId.value, + ), + ) + obj(response)["connectors"]!!.jsonArray.map { val row = it.jsonObject ConnectorCatalogEntry(row.str("slug"), row.str("name"), row.str("description"), row.str("category")) } @@ -188,7 +218,14 @@ class HermesAccountConnectorRepository( override suspend fun accounts() = guarded { - obj(request(WsMethods.CONNECTORS_ACCOUNTS, accountParams()))["accounts"]!!.jsonArray.map { e -> + val response = + caller.call( + RpcMethods.CONNECTORS_ACCOUNTS, + ConnectorsAccountsParams( + profile = AuthManager.activeProfileId.value, + ), + ) + obj(response)["accounts"]!!.jsonArray.map { e -> val r = e.jsonObject ConnectorAccount( r.str("connection_id"), @@ -209,10 +246,11 @@ class HermesAccountConnectorRepository( require(connectionId.isNotBlank()) val response = obj( - request( - WsMethods.CONNECTORS_ACCOUNTS_REMOVE, - accountParams( - mapOf("connection_id" to connectionId), + caller.call( + RpcMethods.CONNECTORS_ACCOUNTS_REMOVE, + ConnectorsAccountsRemoveParams( + connectionId = connectionId, + profile = AuthManager.activeProfileId.value, ), ), ) @@ -225,12 +263,16 @@ class HermesAccountConnectorRepository( refresh: Boolean, ) = guarded { require(ConnectorRepository.isValidSlug(slug)) - val params = - buildMap { - put("slug", slug) - if (refresh) put("refresh", true) - } - obj(request(WsMethods.CONNECTORS_TOOLS, accountParams(params)))["tools"]!!.jsonArray.map { e -> + val response = + caller.call( + RpcMethods.CONNECTORS_TOOLS, + ConnectorsToolsParams( + slug = slug, + refresh = if (refresh) true else null, + profile = AuthManager.activeProfileId.value, + ), + ) + obj(response)["tools"]!!.jsonArray.map { e -> val r = e.jsonObject ConnectorTool( r.str("slug"), @@ -245,7 +287,15 @@ class HermesAccountConnectorRepository( override suspend fun policy() = guarded { - val root = obj(request(WsMethods.CONNECTORS_POLICY_GET, accountParams())) + val root = + obj( + caller.call( + RpcMethods.CONNECTORS_POLICY_GET, + ConnectorsPolicyGetParams( + profile = AuthManager.activeProfileId.value, + ), + ), + ) val layers = root.array("layers").map { it.jsonObject } val member = layers.firstOrNull { it.str("kind") == "member" } parsePolicy(root["effective"]!!.jsonObject).copy( @@ -293,13 +343,12 @@ class HermesAccountConnectorRepository( check(scope == AuthManager.currentDataScope()) { "Account scope changed" } val result = obj( - request( - WsMethods.CONNECTORS_POLICY_SET, - accountParams( - mapOf( - "change" to change, - "expected_revision" to expectedRevision, - ), + caller.call( + RpcMethods.CONNECTORS_POLICY_SET, + ConnectorsPolicySetParams( + change = ConnectorParser.toJsonElement(change) as JsonObject, + expectedRevision = expectedRevision, + profile = AuthManager.activeProfileId.value, ), ), ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt index a6535881..70816e73 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt @@ -3,6 +3,12 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.model.ConnectorConnectResult import com.m57.hermescontrol.data.model.ConnectorError import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.coroutines.CancellationException /** @@ -42,19 +48,7 @@ interface ConnectorRepository { * Default implementation of [ConnectorRepository] backed by [HermesWsClient]. */ class HermesConnectorRepository( - private val rpcRequest: suspend (method: String, params: 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, ) : ConnectorRepository { override suspend fun listConnectors(sessionId: String): ConnectorListResult { if (sessionId.isBlank()) { @@ -64,8 +58,8 @@ class HermesConnectorRepository( } return try { - val params = mapOf("owner" to ConnectorRepository.sessionOwner(sessionId)) - val result = rpcRequest(WsMethods.CONNECTORS_LIST, params) + val params = ConnectorsListParams(owner = ConnectorOwner.session(sessionId)) + val result = caller.call(RpcMethods.CONNECTORS_LIST, params) ConnectorParser.parseListResult(result) } catch (e: CancellationException) { throw e @@ -107,12 +101,12 @@ class HermesConnectorRepository( return try { val params = - mapOf( - "owner" to ConnectorRepository.sessionOwner(sessionId), - "connectors" to connectors, - "reconnect" to reconnect, + ConnectorsConnectParams( + owner = ConnectorOwner.session(sessionId), + connectors = connectors, + reconnect = reconnect, ) - val result = rpcRequest(WsMethods.CONNECTORS_CONNECT, params) + val result = caller.call(RpcMethods.CONNECTORS_CONNECT, params) ConnectorParser.parseConnectResult(result) } catch (e: CancellationException) { throw e diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Connectors.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Connectors.kt new file mode 100644 index 00000000..b00a2c5c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Connectors.kt @@ -0,0 +1,74 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class ConnectorOwner( + @SerialName("type") val type: String, + @SerialName("session_id") val sessionId: String? = null, +) { + companion object { + fun account() = ConnectorOwner("account") + + fun session(sessionId: String) = ConnectorOwner("session", sessionId) + } +} + +@Serializable +data class ConnectorsListParams( + @SerialName("owner") val owner: ConnectorOwner, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsConnectParams( + @SerialName("owner") val owner: ConnectorOwner, + @SerialName("connectors") val connectors: List, + @SerialName("reconnect") val reconnect: Boolean? = null, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsOperationStatusParams( + @SerialName("owner") val owner: ConnectorOwner, + @SerialName("op_id") val opId: String, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsCatalogParams( + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsAccountsParams( + @SerialName("connector") val connector: String? = null, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsAccountsRemoveParams( + @SerialName("connection_id") val connectionId: String, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsPolicyGetParams( + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsPolicySetParams( + @SerialName("change") val change: JsonObject, + @SerialName("expected_revision") val expectedRevision: String, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsToolsParams( + @SerialName("slug") val slug: String, + @SerialName("refresh") val refresh: Boolean? = null, + @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 80722210..a123c18d 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 @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.ws.contract +import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsMethods import kotlinx.serialization.KSerializer import kotlinx.serialization.json.JsonElement @@ -18,6 +19,20 @@ interface TypedRpcSender { ): String } +interface TypedRpcCaller { + suspend fun call( + method: RpcMethod, + params: P, + ): R +} + +object HermesRpcCaller : TypedRpcCaller { + override suspend fun call( + method: RpcMethod, + params: P, + ): R = HermesWsClient.call(method, params) +} + object RpcMethods { val SESSION_EVENTS_SINCE: RpcMethod = RpcMethod( @@ -228,6 +243,78 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_LIST: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_LIST, + ConnectorsListParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_CONNECT: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_CONNECT, + ConnectorsConnectParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_OPERATION_STATUS: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_OPERATION_STATUS, + ConnectorsOperationStatusParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_CATALOG: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_CATALOG, + ConnectorsCatalogParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_ACCOUNTS: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_ACCOUNTS, + ConnectorsAccountsParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_ACCOUNTS_REMOVE: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_ACCOUNTS_REMOVE, + ConnectorsAccountsRemoveParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_POLICY_GET: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_POLICY_GET, + ConnectorsPolicyGetParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_POLICY_SET: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_POLICY_SET, + ConnectorsPolicySetParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_TOOLS: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_TOOLS, + ConnectorsToolsParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -260,5 +347,14 @@ object RpcMethods { VAULT_SOURCE_SET, VAULT_UNLOCK, VAULT_LOCK, + CONNECTORS_LIST, + CONNECTORS_CONNECT, + CONNECTORS_OPERATION_STATUS, + CONNECTORS_CATALOG, + CONNECTORS_ACCOUNTS, + CONNECTORS_ACCOUNTS_REMOVE, + CONNECTORS_POLICY_GET, + CONNECTORS_POLICY_SET, + CONNECTORS_TOOLS, ) } 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 c13f06b0..8db2b0f4 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 @@ -28,7 +28,7 @@ class AccountConnectorRepositoryTest { runTest { val calls = mutableListOf>>() val repo = - HermesAccountConnectorRepository { method, params -> + accountRepo { method, params -> calls += method to params operationFixture() } @@ -57,7 +57,7 @@ class AccountConnectorRepositoryTest { runTest { val calls = mutableListOf>>() val repo = - HermesAccountConnectorRepository { method, params -> + accountRepo { method, params -> calls += method to params operationFixture() } @@ -92,7 +92,7 @@ class AccountConnectorRepositoryTest { runTest { val calls = mutableListOf>>() val repo = - HermesAccountConnectorRepository { method, params -> + accountRepo { method, params -> calls += method to params when (method) { WsMethods.CONNECTORS_CATALOG -> { @@ -165,7 +165,7 @@ class AccountConnectorRepositoryTest { @Test fun `policy keeps member revision separate from effective and respects inherited rules`() = runTest { - val repo = HermesAccountConnectorRepository { _, _ -> policyFixture() } + val repo = accountRepo { _, _ -> policyFixture() } val policy = (repo.policy() as AccountConnectorResult.Success).value assertEquals("effective-revision", policy.revision) assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", policy.member?.revision) @@ -180,7 +180,7 @@ class AccountConnectorRepositoryTest { runTest { val calls = mutableListOf>>() val repo = - HermesAccountConnectorRepository { method, params -> + accountRepo { method, params -> calls += method to params if (method == WsMethods.CONNECTORS_POLICY_GET) { policyFixture() @@ -208,10 +208,13 @@ class AccountConnectorRepositoryTest { @Test fun `malformed authorization does not look like a successful connect`() = runTest { - val repo = HermesAccountConnectorRepository { _, _ -> mapOf("status" to "initiated") } + val repo = accountRepo { _, _ -> mapOf("status" to "initiated") } assertTrue(repo.connect(listOf("drive")) is AccountConnectorResult.Failure) } + private fun accountRepo(handler: suspend (String, Map) -> Any?) = + HermesAccountConnectorRepository(rpc = handler, caller = fakeCaller(handler)) + private fun operationFixture() = mapOf( "op_id" to "op-a", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt index 958bff9a..903da505 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt @@ -34,11 +34,12 @@ class ConnectorRepositoryTest { val repo = HermesConnectorRepository( - rpcRequest = { method, params -> - recordedMethod = method - recordedParams = params - mapOf("available" to true, "connectors" to emptyList>()) - }, + caller = + fakeCaller { method, params -> + recordedMethod = method + recordedParams = params + mapOf("available" to true, "connectors" to emptyList>()) + }, ) val result = repo.listConnectors("sess-abc-123") @@ -61,17 +62,18 @@ class ConnectorRepositoryTest { val repo = HermesConnectorRepository( - rpcRequest = { method, params -> - recordedMethod = method - recordedParams = params - mapOf( - "results" to - listOf( - mapOf("connector" to "linear", "status" to "initiated"), - ), - "summary" to mapOf("total" to 1, "initiated" to 1), - ) - }, + caller = + fakeCaller { method, params -> + recordedMethod = method + recordedParams = params + mapOf( + "results" to + listOf( + mapOf("connector" to "linear", "status" to "initiated"), + ), + "summary" to mapOf("total" to 1, "initiated" to 1), + ) + }, ) val result = @@ -103,16 +105,17 @@ class ConnectorRepositoryTest { val repo = HermesConnectorRepository( - rpcRequest = { _, params -> - recordedParams = params - mapOf( - "results" to - listOf( - mapOf("connector" to "slack", "status" to "active"), - ), - "summary" to mapOf("total" to 1, "active" to 1), - ) - }, + caller = + fakeCaller { _, params -> + recordedParams = params + mapOf( + "results" to + listOf( + mapOf("connector" to "slack", "status" to "active"), + ), + "summary" to mapOf("total" to 1, "active" to 1), + ) + }, ) repo.connect(sessionId = "sess-789", connectors = listOf("slack")) @@ -125,10 +128,11 @@ class ConnectorRepositoryTest { var rpcCalled = false val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - rpcCalled = true - null - }, + caller = + fakeCaller { _, _ -> + rpcCalled = true + null + }, ) val r1 = repo.listConnectors("") @@ -147,10 +151,11 @@ class ConnectorRepositoryTest { var rpcCalled = false val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - rpcCalled = true - null - }, + caller = + fakeCaller { _, _ -> + rpcCalled = true + null + }, ) // Blank session_id @@ -172,10 +177,11 @@ class ConnectorRepositoryTest { var rpcCalled = false val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - rpcCalled = true - null - }, + caller = + fakeCaller { _, _ -> + rpcCalled = true + null + }, ) // Invalid slugs: uppercase, starting with hyphen/underscore, containing spaces/symbols @@ -210,9 +216,10 @@ class ConnectorRepositoryTest { runBlocking { val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw CancellationException("coroutine cancelled") - }, + caller = + fakeCaller { _, _ -> + throw CancellationException("coroutine cancelled") + }, ) try { @@ -236,13 +243,14 @@ class ConnectorRepositoryTest { // 4031 CONNECTORS_UNAVAILABLE val repo4031 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Connectors unavailable", - code = 4031, - data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))), - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Connectors unavailable", + code = 4031, + data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))), + ) + }, ) val r4031 = repo4031.listConnectors("sess-1") assertTrue(r4031 is ConnectorListResult.Error) @@ -251,13 +259,14 @@ class ConnectorRepositoryTest { // 4001 NOT_OWNER val repo4001 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Session not owned", - code = 4001, - data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))), - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Session not owned", + code = 4001, + data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))), + ) + }, ) val r4001 = repo4001.connect("sess-1", listOf("linear")) assertTrue(r4001 is ConnectorConnectResult.Error) @@ -266,13 +275,14 @@ class ConnectorRepositoryTest { // 5033 UNSUPPORTED_RUNTIME val repo5033 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Compute host required", - code = 5033, - data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))), - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Compute host required", + code = 5033, + data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))), + ) + }, ) val r5033 = repo5033.connect("sess-1", listOf("linear")) assertTrue(r5033 is ConnectorConnectResult.Error) @@ -281,13 +291,14 @@ class ConnectorRepositoryTest { // 5034 INVALID_CONNECTOR_RESPONSE val repo5034 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Invalid connector response", - code = 5034, - data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))), - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Invalid connector response", + code = 5034, + data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))), + ) + }, ) val r5034 = repo5034.connect("sess-1", listOf("linear")) assertTrue(r5034 is ConnectorConnectResult.Error) @@ -296,13 +307,14 @@ class ConnectorRepositoryTest { // -32601 Unsupported backend val repo32601 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Method not found", - code = -32601, - data = null, - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Method not found", + code = -32601, + data = null, + ) + }, ) val r32601 = repo32601.listConnectors("sess-1") assertTrue(r32601 is ConnectorListResult.Error) @@ -314,9 +326,10 @@ class ConnectorRepositoryTest { runBlocking { val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw java.io.IOException("Connection reset by peer") - }, + caller = + fakeCaller { _, _ -> + throw java.io.IOException("Connection reset by peer") + }, ) val rList = repo.listConnectors("sess-1") diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/FakeTypedRpcCaller.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/FakeTypedRpcCaller.kt new file mode 100644 index 00000000..a133a003 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/FakeTypedRpcCaller.kt @@ -0,0 +1,22 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller + +/** + * Test double for [TypedRpcCaller]: encodes the typed params exactly as the wire would, hands the + * resulting map to [handler] together with the method name, and returns its value as a JsonElement. + */ +internal fun fakeCaller(handler: suspend (String, Map) -> Any?): TypedRpcCaller = + object : TypedRpcCaller { + @Suppress("UNCHECKED_CAST") + override suspend fun call( + method: RpcMethod, + params: P, + ): R { + val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params) + val map = (encoded.toAny() as? Map).orEmpty() + return ConnectorParser.toJsonElement(handler(method.name, map)) as R + } + } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectorsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectorsTest.kt new file mode 100644 index 00000000..0e4009dc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectorsTest.kt @@ -0,0 +1,358 @@ +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.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicySetParams +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.booleanOrNull +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 HermesWsClientConnectorsTest { + 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 typedCallConnectorsConnectEncodesOwnerAccountReconnectTrueAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("op_id", "op-connect-123") + 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.connect") { + 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_CONNECT, + ConnectorsConnectParams( + owner = ConnectorOwner.account(), + connectors = listOf("slack", "github"), + reconnect = true, + ), + ) + } + + 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.connect", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put( + "connectors", + buildJsonArray { + add(JsonPrimitive("slack")) + add(JsonPrimitive("github")) + }, + ) + put("reconnect", true) + } + assertEquals(expectedParams, params) + assertEquals(true, (params?.get("reconnect") as? JsonPrimitive)?.booleanOrNull) + assertFalse(params?.containsKey("profile") ?: true) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallConnectorsPolicySetEncodesNestedChangeWithArrayAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("revision", 5) + put("applied", 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 == "connectors.policy.set") { + 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 nestedChange = + buildJsonObject { + put("policy", "whitelist") + put( + "allowed_connectors", + buildJsonArray { + add(JsonPrimitive("notion")) + add(JsonPrimitive("linear")) + }, + ) + put( + "rules", + buildJsonObject { + put("require_auth", true) + }, + ) + } + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.CONNECTORS_POLICY_SET, + ConnectorsPolicySetParams( + change = nestedChange, + expectedRevision = "4", + profile = "team-profile", + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("connectors.policy.set", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("change", nestedChange) + put("expected_revision", "4") + put("profile", "team-profile") + } + assertEquals(expectedParams, params) + assertEquals(nestedChange, params?.get("change")) + assertEquals(serverResultJson, result) + } + + @Test + fun hermesRpcCallerCallDelegatesToHermesWsClientCallAndEncodesFrameParamsUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put( + "connectors", + buildJsonArray { + add( + buildJsonObject { + put("id", "c1") + put("status", "connected") + }, + ) + }, + ) + } + + 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.list") { + 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_LIST, + ConnectorsListParams( + owner = ConnectorOwner.session("s1"), + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("connectors.list", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "s1") + }, + ) + } + assertEquals(expectedParams, params) + 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 dae2bca2..aeee33fa 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 @@ -316,17 +316,6 @@ class RawRpcParamsRatchetTest { */ val BASELINE: List = listOf( - "data/ws/AccountConnectorRepository.kt::CONNECTORS_ACCOUNTS", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_ACCOUNTS_REMOVE", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_CATALOG", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_CONNECT", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_LIST", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_OPERATION_STATUS", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_POLICY_GET", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_POLICY_SET", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_TOOLS", - "data/ws/ConnectorRepository.kt::CONNECTORS_CONNECT", - "data/ws/ConnectorRepository.kt::CONNECTORS_LIST", "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", "data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS", "data/ws/PluginManageRepository.kt::PLUGINS_MANAGE", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectorsTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectorsTypesTest.kt new file mode 100644 index 00000000..9203210c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectorsTypesTest.kt @@ -0,0 +1,539 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +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.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +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 ConnectorsTypesTest { + @Test + fun connectorOwnerAccountEncodesToTypeAccountWithoutSessionId() { + val owner = ConnectorOwner.account() + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorOwner.serializer(), owner) + + val expected = + buildJsonObject { + put("type", "account") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("type"), (encoded as JsonObject).keys) + } + + @Test + fun connectorOwnerSessionEncodesToTypeSessionAndSessionId() { + val owner = ConnectorOwner.session("s1") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorOwner.serializer(), owner) + + val expected = + buildJsonObject { + put("type", "session") + put("session_id", "s1") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("type", "session_id"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsListParamsEncodesOwnerAndProfileWhenProfileProvided() { + val params = ConnectorsListParams(owner = ConnectorOwner.account(), profile = "work") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsListParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsListParamsOmitsProfileWhenNull() { + val params = ConnectorsListParams(owner = ConnectorOwner.session("s1"), profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsListParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "s1") + }, + ) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsConnectParamsEncodesAllFieldsWhenPresent() { + val params = + ConnectorsConnectParams( + owner = ConnectorOwner.account(), + connectors = listOf("slack", "github"), + reconnect = true, + profile = "personal", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put( + "connectors", + buildJsonArray { + add(JsonPrimitive("slack")) + add(JsonPrimitive("github")) + }, + ) + put("reconnect", true) + put("profile", "personal") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "connectors", "reconnect", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsConnectParamsEncodesExplicitFalseForReconnect() { + val params = + ConnectorsConnectParams( + owner = ConnectorOwner.session("s2"), + connectors = listOf("jira"), + reconnect = false, + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "s2") + }, + ) + put( + "connectors", + buildJsonArray { + add(JsonPrimitive("jira")) + }, + ) + put("reconnect", false) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "connectors", "reconnect"), (encoded as JsonObject).keys) + assertFalse((encoded as JsonObject).containsKey("profile")) + } + + @Test + fun connectorsConnectParamsOmitsNullReconnectAndProfile() { + val params = + ConnectorsConnectParams( + owner = ConnectorOwner.account(), + connectors = emptyList(), + reconnect = null, + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("connectors", buildJsonArray {}) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "connectors"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsOperationStatusParamsEncodesAllFieldsWhenPresent() { + val params = + ConnectorsOperationStatusParams( + owner = ConnectorOwner.session("sess-1"), + opId = "op-99", + profile = "work", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-1") + }, + ) + put("op_id", "op-99") + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsOperationStatusParamsOmitsNullProfile() { + val params = + ConnectorsOperationStatusParams( + owner = ConnectorOwner.account(), + opId = "op-100", + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("op_id", "op-100") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsCatalogParamsEncodesProfileWhenPresent() { + val params = ConnectorsCatalogParams(profile = "default") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsCatalogParams.serializer(), params) + + val expected = + buildJsonObject { + put("profile", "default") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsCatalogParamsOmitsNullProfileResultingInEmptyJsonObject() { + val params = ConnectorsCatalogParams(profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsCatalogParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun connectorsAccountsParamsEncodesAllFieldsWhenPresent() { + val params = ConnectorsAccountsParams(connector = "google", profile = "work") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params) + + val expected = + buildJsonObject { + put("connector", "google") + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("connector", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsAccountsParamsOmitsNullFieldsResultingInEmptyJsonObject() { + val params = ConnectorsAccountsParams(connector = null, profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun connectorsAccountsParamsEncodesConnectorOnlyWhenProfileNull() { + val params = ConnectorsAccountsParams(connector = "github", profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params) + + val expected = + buildJsonObject { + put("connector", "github") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("connector"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsAccountsRemoveParamsEncodesConnectionIdAndProfile() { + val params = ConnectorsAccountsRemoveParams(connectionId = "conn-123", profile = "work") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsRemoveParams.serializer(), params) + + val expected = + buildJsonObject { + put("connection_id", "conn-123") + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("connection_id", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsAccountsRemoveParamsOmitsNullProfile() { + val params = ConnectorsAccountsRemoveParams(connectionId = "conn-456", profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsRemoveParams.serializer(), params) + + val expected = + buildJsonObject { + put("connection_id", "conn-456") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("connection_id"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsPolicyGetParamsEncodesProfileWhenPresent() { + val params = ConnectorsPolicyGetParams(profile = "custom-profile") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicyGetParams.serializer(), params) + + val expected = + buildJsonObject { + put("profile", "custom-profile") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsPolicyGetParamsOmitsNullProfileResultingInEmptyJsonObject() { + val params = ConnectorsPolicyGetParams(profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicyGetParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun connectorsPolicySetParamsEncodesNestedChangeWithArraysUntouched() { + val nestedChange = + buildJsonObject { + put("action", "allow") + put( + "tools", + buildJsonArray { + add(JsonPrimitive("gmail.send")) + add(JsonPrimitive("gmail.list")) + }, + ) + put( + "scope", + buildJsonObject { + put("level", "read-write") + put( + "allowed_users", + buildJsonArray { + add(JsonPrimitive("alice")) + add(JsonPrimitive("bob")) + }, + ) + }, + ) + } + val params = + ConnectorsPolicySetParams( + change = nestedChange, + expectedRevision = "4", + profile = "work", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicySetParams.serializer(), params) + + val expected = + buildJsonObject { + put("change", nestedChange) + put("expected_revision", "4") + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("change", "expected_revision", "profile"), (encoded as JsonObject).keys) + assertEquals(nestedChange, (encoded as JsonObject)["change"]) + } + + @Test + fun connectorsPolicySetParamsOmitsNullProfile() { + val change = buildJsonObject { put("enabled", true) } + val params = + ConnectorsPolicySetParams( + change = change, + expectedRevision = "1", + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicySetParams.serializer(), params) + + val expected = + buildJsonObject { + put("change", change) + put("expected_revision", "1") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("change", "expected_revision"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsToolsParamsEncodesAllFieldsWhenPresent() { + val params = + ConnectorsToolsParams( + slug = "gmail", + refresh = true, + profile = "work", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params) + + val expected = + buildJsonObject { + put("slug", "gmail") + put("refresh", true) + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("slug", "refresh", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsToolsParamsEncodesExplicitFalseForRefresh() { + val params = + ConnectorsToolsParams( + slug = "slack", + refresh = false, + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params) + + val expected = + buildJsonObject { + put("slug", "slack") + put("refresh", false) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("slug", "refresh"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsToolsParamsOmitsNullRefreshAndProfile() { + val params = + ConnectorsToolsParams( + slug = "github", + refresh = null, + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params) + + val expected = + buildJsonObject { + put("slug", "github") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("slug"), (encoded as JsonObject).keys) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("connectors.list", RpcMethods.CONNECTORS_LIST.name) + assertEquals("connectors.connect", RpcMethods.CONNECTORS_CONNECT.name) + assertEquals("connectors.operation.status", RpcMethods.CONNECTORS_OPERATION_STATUS.name) + assertEquals("connectors.catalog", RpcMethods.CONNECTORS_CATALOG.name) + assertEquals("connectors.accounts", RpcMethods.CONNECTORS_ACCOUNTS.name) + assertEquals("connectors.accounts.remove", RpcMethods.CONNECTORS_ACCOUNTS_REMOVE.name) + assertEquals("connectors.policy.get", RpcMethods.CONNECTORS_POLICY_GET.name) + assertEquals("connectors.policy.set", RpcMethods.CONNECTORS_POLICY_SET.name) + assertEquals("connectors.tools", RpcMethods.CONNECTORS_TOOLS.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_LIST)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_CONNECT)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_OPERATION_STATUS)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_CATALOG)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_ACCOUNTS)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_ACCOUNTS_REMOVE)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_POLICY_GET)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_POLICY_SET)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_TOOLS)) + } + + @Test + fun jsonElementPassthroughMethodsDecodeResultUntouched() { + val arbitraryPayload = + buildJsonObject { + put("status", "ok") + put( + "items", + buildJsonArray { + add(JsonPrimitive("elem1")) + add(JsonPrimitive("elem2")) + }, + ) + put("nested", buildJsonObject { put("revision", 10) }) + } + + val methods = + listOf( + RpcMethods.CONNECTORS_LIST, + RpcMethods.CONNECTORS_CONNECT, + RpcMethods.CONNECTORS_OPERATION_STATUS, + RpcMethods.CONNECTORS_CATALOG, + RpcMethods.CONNECTORS_ACCOUNTS, + RpcMethods.CONNECTORS_ACCOUNTS_REMOVE, + RpcMethods.CONNECTORS_POLICY_GET, + RpcMethods.CONNECTORS_POLICY_SET, + RpcMethods.CONNECTORS_TOOLS, + ) + + for (method in methods) { + val decoded: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + method.result, + arbitraryPayload, + ) + assertEquals("Method " + method.name + " must pass raw result untouched", arbitraryPayload, decoded) + } + } +}