refactor(#1375): type connectors repository RPC params (#1387)

This commit is contained in:
m57 authored and GitHub committed 2026-09-29 20:27:27 +03:00
1 parent 60c13f10ce
commit 8ae2ddbbee
10 files changed
+1286 -149

No files matched your search

@@ -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<String, Any> {
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,
),
),
)
@@ -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<String, Any>) -> Any? = { method, params ->
val deferred = HermesWsClient.request(method, params)
try {
deferred.await()
} catch (e: CancellationException) {
deferred.cancel(e)
throw e
} finally {
if (!deferred.isCompleted) {
deferred.cancel()
}
}
},
private val caller: TypedRpcCaller = HermesRpcCaller,
) : 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
@@ -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<String>,
@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,
)
@@ -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 <P, R> call(
method: RpcMethod<P, R>,
params: P,
): R
}
object HermesRpcCaller : TypedRpcCaller {
override suspend fun <P, R> call(
method: RpcMethod<P, R>,
params: P,
): R = HermesWsClient.call(method, params)
}
object RpcMethods {
val SESSION_EVENTS_SINCE: RpcMethod<SessionEventsSinceParams, SessionEventsSinceResult> =
RpcMethod(
@@ -228,6 +243,78 @@ object RpcMethods {
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_LIST: RpcMethod<ConnectorsListParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_LIST,
ConnectorsListParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_CONNECT: RpcMethod<ConnectorsConnectParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_CONNECT,
ConnectorsConnectParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_OPERATION_STATUS: RpcMethod<ConnectorsOperationStatusParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_OPERATION_STATUS,
ConnectorsOperationStatusParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_CATALOG: RpcMethod<ConnectorsCatalogParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_CATALOG,
ConnectorsCatalogParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_ACCOUNTS: RpcMethod<ConnectorsAccountsParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_ACCOUNTS,
ConnectorsAccountsParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_ACCOUNTS_REMOVE: RpcMethod<ConnectorsAccountsRemoveParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_ACCOUNTS_REMOVE,
ConnectorsAccountsRemoveParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_POLICY_GET: RpcMethod<ConnectorsPolicyGetParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_POLICY_GET,
ConnectorsPolicyGetParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_POLICY_SET: RpcMethod<ConnectorsPolicySetParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_POLICY_SET,
ConnectorsPolicySetParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_TOOLS: RpcMethod<ConnectorsToolsParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_TOOLS,
ConnectorsToolsParams.serializer(),
JsonElement.serializer(),
)
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */
val all: List<RpcMethod<*, *>> =
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,
)
}
@@ -28,7 +28,7 @@ class AccountConnectorRepositoryTest {
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val repo =
HermesAccountConnectorRepository { method, params ->
accountRepo { method, params ->
calls += method to params
operationFixture()
}
@@ -57,7 +57,7 @@ class AccountConnectorRepositoryTest {
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val repo =
HermesAccountConnectorRepository { method, params ->
accountRepo { method, params ->
calls += method to params
operationFixture()
}
@@ -92,7 +92,7 @@ class AccountConnectorRepositoryTest {
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
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<Pair<String, Map<String, Any>>>()
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<String, Any>) -> Any?) =
HermesAccountConnectorRepository(rpc = handler, caller = fakeCaller(handler))
private fun operationFixture() =
mapOf(
"op_id" to "op-a",
@@ -34,11 +34,12 @@ class ConnectorRepositoryTest {
val repo =
HermesConnectorRepository(
rpcRequest = { method, params ->
recordedMethod = method
recordedParams = params
mapOf("available" to true, "connectors" to emptyList<Map<String, Any>>())
},
caller =
fakeCaller { method, params ->
recordedMethod = method
recordedParams = params
mapOf("available" to true, "connectors" to emptyList<Map<String, Any>>())
},
)
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")
@@ -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<String, Any>) -> Any?): TypedRpcCaller =
object : TypedRpcCaller {
@Suppress("UNCHECKED_CAST")
override suspend fun <P, R> call(
method: RpcMethod<P, R>,
params: P,
): R {
val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params)
val map = (encoded.toAny() as? Map<String, Any>).orEmpty()
return ConnectorParser.toJsonElement(handler(method.name, map)) as R
}
}
@@ -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<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
AuthManager.resetAuthStateForTest()
}
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeoutOrNull(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
}
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
}
@Test
fun typedCallConnectorsConnectEncodesOwnerAccountReconnectTrueAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(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<JsonObject?>(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<JsonObject?>(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)
}
}
@@ -316,17 +316,6 @@ class RawRpcParamsRatchetTest {
*/
val BASELINE: List<String> =
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",
@@ -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)
}
}
}