9 files changed
+1377
-51
No files matched your search
@@ -1,8 +1,10 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
@@ -70,6 +72,82 @@ object GatewayContract {
|
||||
required.filter { it !in keys }.forEach { problems += "$method: missing required param '$it'" }
|
||||
return problems
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a Kotlinx [descriptor] for [method]'s params matches the published contract.
|
||||
* Empty list means OK.
|
||||
*/
|
||||
fun paramsDescriptorProblems(
|
||||
method: String,
|
||||
descriptor: SerialDescriptor,
|
||||
): List<String> {
|
||||
if (method !in methods) return listOf("$method is not a contract method")
|
||||
val schema = paramsSchema(method)
|
||||
val propertiesObj = schema["properties"]?.jsonObject ?: JsonObject(emptyMap())
|
||||
val properties = propertiesObj.keys
|
||||
val required =
|
||||
schema["required"]
|
||||
?.jsonArray
|
||||
?.map { it.jsonPrimitive.content }
|
||||
?.toSet()
|
||||
.orEmpty()
|
||||
val problems = mutableListOf<String>()
|
||||
|
||||
val elementNames = (0 until descriptor.elementsCount).map { descriptor.getElementName(it) }.toSet()
|
||||
|
||||
for (i in 0 until descriptor.elementsCount) {
|
||||
val name = descriptor.getElementName(i)
|
||||
if (name !in properties) {
|
||||
problems +=
|
||||
"$method: unknown param '$name' (backend forbids extra keys; allowed: ${properties.sorted()})"
|
||||
}
|
||||
}
|
||||
|
||||
val missingRequired =
|
||||
required.filter { reqName ->
|
||||
reqName !in elementNames && reqName != "profile"
|
||||
}
|
||||
for (missing in missingRequired) {
|
||||
problems += "$method: missing required param '$missing'"
|
||||
}
|
||||
|
||||
for (i in 0 until descriptor.elementsCount) {
|
||||
val name = descriptor.getElementName(i)
|
||||
if (name in required) {
|
||||
if (descriptor.isElementOptional(i)) {
|
||||
problems += "$method: required param '$name' must not be optional"
|
||||
}
|
||||
val propSchema = propertiesObj[name]?.jsonObject
|
||||
val allowsNull = propSchema?.let { propertyAllowsNull(it) } ?: false
|
||||
if (descriptor.getElementDescriptor(i).isNullable && !allowsNull) {
|
||||
problems += "$method: required param '$name' must not be nullable unless schema allows null"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return problems
|
||||
}
|
||||
|
||||
private fun propertyAllowsNull(propertySchema: JsonObject): Boolean {
|
||||
val typeElement = propertySchema["type"]
|
||||
if (typeElement != null) {
|
||||
if (typeElement is JsonArray && typeElement.any { it.jsonPrimitive.content == "null" }) {
|
||||
return true
|
||||
}
|
||||
if (typeElement is JsonPrimitive && typeElement.content == "null") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
val anyOf = propertySchema["anyOf"]?.jsonArray
|
||||
if (anyOf != null && anyOf.any { it.jsonObject["type"]?.jsonPrimitive?.content == "null" }) {
|
||||
return true
|
||||
}
|
||||
val oneOf = propertySchema["oneOf"]?.jsonArray
|
||||
if (oneOf != null && oneOf.any { it.jsonObject["type"]?.jsonPrimitive?.content == "null" }) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -75,6 +76,20 @@ class GatewayContractTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedParamsMatchTheContract() {
|
||||
val methods = RpcMethods.all
|
||||
assertTrue("RpcMethods.all must not be empty", methods.isNotEmpty())
|
||||
val names = methods.map { it.name }
|
||||
assertEquals("RpcMethods.all names must be unique", names.distinct(), names)
|
||||
|
||||
val problems =
|
||||
methods.flatMap { method ->
|
||||
GatewayContract.paramsDescriptorProblems(method.name, method.params.descriptor)
|
||||
}
|
||||
assertTrue(problems.joinToString("\n"), problems.isEmpty())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Methods ChatViewModel.handleServerRequest routes; keep in sync with its `when`. */
|
||||
val APP_HANDLED_SERVER_REQUESTS =
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
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.RpcMethod
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||
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.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
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.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class HermesWsClientTypedCallTest {
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
|
||||
@Serializable
|
||||
private data class ScopedDummyParams(
|
||||
val query: String,
|
||||
val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ScopedDummyResult(
|
||||
val status: String? = null,
|
||||
)
|
||||
|
||||
private val sessionListMethod =
|
||||
RpcMethod(
|
||||
name = WsMethods.SESSION_LIST,
|
||||
params = ScopedDummyParams.serializer(),
|
||||
result = ScopedDummyResult.serializer(),
|
||||
)
|
||||
|
||||
@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 {
|
||||
withTimeout(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 } }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun getPendingCalls(): ConcurrentHashMap<String, Any> {
|
||||
val field = HermesWsClient::class.java.getDeclaredField("pendingCalls")
|
||||
field.isAccessible = true
|
||||
return field.get(HermesWsClient) as ConcurrentHashMap<String, Any>
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callPutsExactFrameOnTheWire() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
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 == WsMethods.SESSION_EVENTS_SINCE) {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":{"latest_seq":7,"truncated":false}}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_EVENTS_SINCE,
|
||||
SessionEventsSinceParams(sessionId = "s1", lastSeen = 7),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertEquals(WsMethods.SESSION_EVENTS_SINCE, (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
put("last_seen", 7)
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
|
||||
val lastSeenElement = params!!["last_seen"] as? JsonPrimitive
|
||||
assertNotNull(lastSeenElement)
|
||||
assertTrue(lastSeenElement!!.isString.not())
|
||||
assertEquals(7, lastSeenElement.content.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callDecodesTypedResultOnSuccess() {
|
||||
val opened = CountDownLatch(1)
|
||||
|
||||
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 id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":{"epoch":"ep-42","latest_seq":100,"truncated":true,"events":[{"type":"test"}]}}""",
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_EVENTS_SINCE,
|
||||
SessionEventsSinceParams(sessionId = "s1", lastSeen = 5),
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("ep-42", result.epoch)
|
||||
assertEquals(100, result.latestSeq)
|
||||
assertEquals(true, result.truncated)
|
||||
assertEquals(1, result.events?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callThrowsHermesRpcExceptionOnErrorResponse() {
|
||||
val opened = CountDownLatch(1)
|
||||
|
||||
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 id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","error":{"code":4001,"message":"Access denied: session ownership required","data":{"reason":"NOT_OWNER"}}}""",
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_EVENTS_SINCE,
|
||||
SessionEventsSinceParams(sessionId = "s1", lastSeen = 1),
|
||||
)
|
||||
}
|
||||
fail("Expected HermesRpcException to be thrown")
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
assertEquals(4001, e.code)
|
||||
assertEquals("Access denied: session ownership required", e.message)
|
||||
val dataObj = e.data as? JsonObject
|
||||
assertNotNull(dataObj)
|
||||
assertEquals("NOT_OWNER", (dataObj!!["reason"] as? JsonPrimitive)?.content)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileScopingInjectsActiveProfileWhenSet() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
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 == WsMethods.SESSION_LIST) {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""")
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
AuthManager.setActiveProfileId("work-profile")
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
sessionListMethod,
|
||||
ScopedDummyParams(query = "active"),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
assertEquals("work-profile", (params!!["profile"] as? JsonPrimitive)?.content)
|
||||
assertEquals("active", (params["query"] as? JsonPrimitive)?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileScopingPreservesExplicitProfile() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
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 == WsMethods.SESSION_LIST) {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""")
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
AuthManager.setActiveProfileId("work-profile")
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
sessionListMethod,
|
||||
ScopedDummyParams(query = "active", profile = "explicit-override"),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
assertEquals("explicit-override", (params!!["profile"] as? JsonPrimitive)?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileScopingDoesNotInjectWhenNoActiveProfile() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
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 == WsMethods.SESSION_LIST) {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""")
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
sessionListMethod,
|
||||
ScopedDummyParams(query = "active"),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
assertFalse("Profile key must not be present when no active profile", params!!.containsKey("profile"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callFailsOnTimeoutWhenServerDoesNotReply() {
|
||||
val opened = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
// Do not reply
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val thrown =
|
||||
runCatching {
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_EVENTS_SINCE,
|
||||
SessionEventsSinceParams(sessionId = "s1", lastSeen = 1),
|
||||
timeoutMs = 50L,
|
||||
)
|
||||
}
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertTrue(
|
||||
"Timeout must surface as HermesRpcException, got: $thrown",
|
||||
thrown is HermesWsClient.HermesRpcException,
|
||||
)
|
||||
val rpc = thrown as HermesWsClient.HermesRpcException
|
||||
assertEquals(-1, rpc.code)
|
||||
assertTrue(rpc.message.orEmpty().contains("timed out"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancellingCallingCoroutineCleansPendingCall() {
|
||||
val opened = CountDownLatch(1)
|
||||
val requestReceived = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
requestReceived.countDown()
|
||||
// Do not reply, keeping the call pending
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
runBlocking {
|
||||
val callJob =
|
||||
async(Dispatchers.IO) {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_EVENTS_SINCE,
|
||||
SessionEventsSinceParams(sessionId = "s1", lastSeen = 1),
|
||||
timeoutMs = 10_000L,
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Server should receive request", requestReceived.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val pending = getPendingCalls()
|
||||
assertEquals("Should have exactly 1 pending call while awaiting", 1, pending.size)
|
||||
|
||||
callJob.cancel()
|
||||
|
||||
// Wait briefly for cancellation cleanup to execute
|
||||
withTimeout(2000) {
|
||||
while (pending.isNotEmpty()) {
|
||||
kotlinx.coroutines.delay(20)
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue("Pending calls should be empty after caller coroutine is cancelled", pending.isEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
/**
|
||||
* Ratchet guard preventing new raw Map-param request/send call sites for contract RPC methods (#1375).
|
||||
*
|
||||
* Background:
|
||||
* Issue #1375 migrates raw `HermesWsClient.request(...)`/`.send(...)`/wrapper call sites that pass hand-built
|
||||
* `Map<String, Any>` params to typed `HermesWsClient.call(RpcMethods.X, params)`.
|
||||
*
|
||||
* This test asserts that the set/multiset of raw call sites in `app/src/main/java` matches [BASELINE].
|
||||
* - Adding a new raw call site fails CI: author must migrate to `HermesWsClient.call(RpcMethods.X, params)` or,
|
||||
* for a not-yet-migrated method, consciously add the entry to [BASELINE].
|
||||
* - Migrating a call site to typed `call(...)` makes the baseline entry stale, failing CI until removed.
|
||||
*
|
||||
* Scanner limitations / blind spots:
|
||||
* 1. Indirect wrappers: does not catch wrappers that alias method names through intermediate variables or functions
|
||||
* (e.g. `val m = WsMethods.FOO; send(m)` or helper functions accepting dynamic strings).
|
||||
* 2. Raw string literals: does not catch direct string literals bypassing `WsMethods` (e.g. `send("session.list")`).
|
||||
* 3. Dynamic dispatch: reflection or computed method identifiers are invisible to the static regex scanner.
|
||||
* 4. Custom parameter maps wrapped in separate helper classes or extensions not calling standard request/send.
|
||||
*/
|
||||
class RawRpcParamsRatchetTest {
|
||||
private val wsMethodConstants: Map<String, String> =
|
||||
WsMethods::class.java.declaredFields
|
||||
.filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java }
|
||||
.associate { it.name to it.get(null) as String }
|
||||
|
||||
@Test
|
||||
fun rawRpcCallSitesMatchBaselineRatchet() {
|
||||
val rootDir = resolveMainJavaRoot()
|
||||
val contractMethodNames = GatewayContract.methods
|
||||
val contractConstantNames =
|
||||
wsMethodConstants
|
||||
.filter { (_, methodStr) -> methodStr in contractMethodNames }
|
||||
.keys
|
||||
|
||||
val scannedSites = mutableListOf<String>()
|
||||
rootDir
|
||||
.walkTopDown()
|
||||
.filter { it.isFile && it.extension == "kt" }
|
||||
.filter { !it.path.contains("data/ws/contract") && it.name != "WsMethods.kt" }
|
||||
.forEach { file ->
|
||||
val relativePath = file.relativeTo(rootDir).invariantSeparatorsPath
|
||||
val code = stripComments(file.readText())
|
||||
val sites = scanRawRpcCalls(code, contractConstantNames)
|
||||
for (site in sites) {
|
||||
scannedSites.add("$relativePath::$site")
|
||||
}
|
||||
}
|
||||
|
||||
scannedSites.sort()
|
||||
|
||||
val staleEntries = BASELINE.filter { it !in scannedSites }
|
||||
val newEntries = scannedSites.filter { it !in BASELINE }
|
||||
|
||||
val failureMessage =
|
||||
buildString {
|
||||
if (newEntries.isNotEmpty()) {
|
||||
appendLine(
|
||||
"raw call added: migrate to HermesWsClient.call(RpcMethods...) or, for a not-yet-migrated method, add to BASELINE:",
|
||||
)
|
||||
newEntries.forEach { appendLine(" + $it") }
|
||||
}
|
||||
if (staleEntries.isNotEmpty()) {
|
||||
appendLine("stale baseline entry: remove it (call site was migrated or removed):")
|
||||
staleEntries.forEach { appendLine(" - $it") }
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
failureMessage.trimEnd(),
|
||||
BASELINE,
|
||||
scannedSites,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scannerSelfTestDetectsRawCallsAndIgnoresSafeUsages() {
|
||||
val contractConstants = setOf("SESSION_LIST", "SESSION_CREATE", "PROMPT_SUBMIT")
|
||||
|
||||
val sampleCode =
|
||||
"""
|
||||
// Commented call should be ignored:
|
||||
// wsClient.request(WsMethods.SESSION_LIST, emptyMap())
|
||||
/* Multi-line comment:
|
||||
HermesWsClient.send(WsMethods.SESSION_LIST)
|
||||
*/
|
||||
|
||||
// When branch routing should be ignored:
|
||||
when (method) {
|
||||
WsMethods.SESSION_LIST -> handleList()
|
||||
WsMethods.SESSION_CREATE -> handleCreate()
|
||||
}
|
||||
|
||||
// Comparison checks should be ignored:
|
||||
if (method == WsMethods.SESSION_LIST) return
|
||||
if (method != WsMethods.PROMPT_SUBMIT) return
|
||||
|
||||
// Tracking helpers should be ignored:
|
||||
trackRequest(id, WsMethods.SESSION_LIST)
|
||||
trackSessionRequest(id, WsMethods.SESSION_CREATE, generation)
|
||||
|
||||
// Positional single-line call:
|
||||
HermesWsClient.request(WsMethods.SESSION_LIST, emptyMap())
|
||||
|
||||
// Named argument call across lines:
|
||||
request(
|
||||
method = WsMethods.SESSION_CREATE,
|
||||
params = mapOf("profile" to "test"),
|
||||
)
|
||||
|
||||
// Positional multi-line call:
|
||||
wsClient.send(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
mapOf("text" to "hello"),
|
||||
)
|
||||
""".trimIndent()
|
||||
|
||||
val cleaned = stripComments(sampleCode)
|
||||
val detected = scanRawRpcCalls(cleaned, contractConstants)
|
||||
|
||||
assertEquals(
|
||||
listOf("SESSION_LIST", "SESSION_CREATE", "PROMPT_SUBMIT"),
|
||||
detected,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun resolveMainJavaRoot(): File {
|
||||
val userDir = File(System.getProperty("user.dir") ?: ".")
|
||||
val candidates =
|
||||
listOf(
|
||||
File(userDir, "app/src/main/java/com/m57/hermescontrol"),
|
||||
File(userDir, "src/main/java/com/m57/hermescontrol"),
|
||||
File(userDir, "../app/src/main/java/com/m57/hermescontrol"),
|
||||
)
|
||||
return candidates.firstOrNull { it.isDirectory }
|
||||
?: error("Could not resolve app/src/main/java/com/m57/hermescontrol from $userDir")
|
||||
}
|
||||
|
||||
internal fun stripComments(text: String): String {
|
||||
val result = StringBuilder(text.length)
|
||||
var i = 0
|
||||
val n = text.length
|
||||
var inString = false
|
||||
var inTripleString = false
|
||||
|
||||
while (i < n) {
|
||||
if (!inString && !inTripleString) {
|
||||
if (text.startsWith("\"\"\"", i)) {
|
||||
inTripleString = true
|
||||
result.append("\"\"\"")
|
||||
i += 3
|
||||
} else if (text[i] == '"') {
|
||||
inString = true
|
||||
result.append('"')
|
||||
i++
|
||||
} else if (text.startsWith("//", i)) {
|
||||
var j = text.indexOf('\n', i)
|
||||
if (j == -1) j = n
|
||||
while (i < j) {
|
||||
result.append(' ')
|
||||
i++
|
||||
}
|
||||
} else if (text.startsWith("/*", i)) {
|
||||
var depth = 1
|
||||
var j = i + 2
|
||||
while (j < n && depth > 0) {
|
||||
if (text.startsWith("/*", j)) {
|
||||
depth++
|
||||
j += 2
|
||||
} else if (text.startsWith("*/", j)) {
|
||||
depth--
|
||||
j += 2
|
||||
} else {
|
||||
j++
|
||||
}
|
||||
}
|
||||
while (i < j) {
|
||||
result.append(if (text[i] == '\n') '\n' else ' ')
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
result.append(text[i])
|
||||
i++
|
||||
}
|
||||
} else if (inTripleString) {
|
||||
if (text.startsWith("\"\"\"", i)) {
|
||||
inTripleString = false
|
||||
result.append("\"\"\"")
|
||||
i += 3
|
||||
} else {
|
||||
result.append(text[i])
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
if (text[i] == '\\' && i + 1 < n) {
|
||||
result.append(text[i]).append(text[i + 1])
|
||||
i += 2
|
||||
} else if (text[i] == '"') {
|
||||
inString = false
|
||||
result.append('"')
|
||||
i++
|
||||
} else {
|
||||
result.append(text[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
internal fun scanRawRpcCalls(
|
||||
cleanCode: String,
|
||||
contractConstants: Set<String>,
|
||||
): List<String> {
|
||||
val regex = Regex("""WsMethods\.([A-Za-z0-9_]+)""")
|
||||
val results = mutableListOf<String>()
|
||||
|
||||
for (match in regex.findAll(cleanCode)) {
|
||||
val constantName = match.groupValues[1]
|
||||
if (constantName !in contractConstants) continue
|
||||
|
||||
val startIndex = match.range.first
|
||||
if (isMethodArgumentOfRpcCall(cleanCode, startIndex)) {
|
||||
results.add(constantName)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private fun isMethodArgumentOfRpcCall(
|
||||
cleanCode: String,
|
||||
matchStart: Int,
|
||||
): Boolean {
|
||||
var i = matchStart - 1
|
||||
while (i >= 0 && cleanCode[i].isWhitespace()) {
|
||||
i--
|
||||
}
|
||||
|
||||
var isNamedMethodParam = false
|
||||
if (i >= 0 && cleanCode[i] == '=') {
|
||||
var j = i - 1
|
||||
while (j >= 0 && cleanCode[j].isWhitespace()) {
|
||||
j--
|
||||
}
|
||||
val identEnd = j + 1
|
||||
while (j >= 0 && cleanCode[j].isLetterOrDigit()) {
|
||||
j--
|
||||
}
|
||||
val paramName = cleanCode.substring(j + 1, identEnd)
|
||||
if (paramName == "method") {
|
||||
isNamedMethodParam = true
|
||||
i = j
|
||||
while (i >= 0 && cleanCode[i].isWhitespace()) {
|
||||
i--
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (i < 0) return false
|
||||
val delimiter = cleanCode[i]
|
||||
if (delimiter != '(' && delimiter != ',') return false
|
||||
if (delimiter == ',' && !isNamedMethodParam) {
|
||||
// Positional method argument in Kotlin RPC calls is the first argument directly following '('
|
||||
return false
|
||||
}
|
||||
|
||||
var parenDepth = 0
|
||||
var k = i
|
||||
while (k >= 0) {
|
||||
val c = cleanCode[k]
|
||||
if (c == ')') {
|
||||
parenDepth++
|
||||
} else if (c == '(') {
|
||||
if (parenDepth > 0) {
|
||||
parenDepth--
|
||||
} else {
|
||||
var m = k - 1
|
||||
while (m >= 0 && cleanCode[m].isWhitespace()) {
|
||||
m--
|
||||
}
|
||||
val endFn = m + 1
|
||||
while (m >= 0 &&
|
||||
(cleanCode[m].isLetterOrDigit() || cleanCode[m] == '.' || cleanCode[m] == '_')
|
||||
) {
|
||||
m--
|
||||
}
|
||||
val fnName = cleanCode.substring(m + 1, endFn)
|
||||
val lowerFn = fnName.lowercase()
|
||||
if (fnName in setOf("if", "while", "for", "when")) return false
|
||||
if (lowerFn.contains("track")) return false
|
||||
return true
|
||||
}
|
||||
} else if (c == '{' || c == '}' || c == ';') {
|
||||
return false
|
||||
}
|
||||
k--
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiset of remaining unmigrated raw RPC call sites in `app/src/main/java/com/m57/hermescontrol/`.
|
||||
* Excludes `data/ws/HermesWsClient.kt::SESSION_EVENTS_SINCE` which is migrated to typed `RpcMethods`.
|
||||
* Notice HermesWsClient.kt retains its other raw uses: `CLIENT_CAPABILITIES`, `PROMPT_SUBMIT`,
|
||||
* and `SESSION_REDIRECT`.
|
||||
*/
|
||||
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/BillingRepository.kt::SUBSCRIPTION_CHANGE",
|
||||
"data/ws/BillingRepository.kt::SUBSCRIPTION_PREVIEW",
|
||||
"data/ws/BillingRepository.kt::SUBSCRIPTION_RESUME",
|
||||
"data/ws/BillingRepository.kt::SUBSCRIPTION_STATE",
|
||||
"data/ws/BillingRepository.kt::SUBSCRIPTION_UPGRADE",
|
||||
"data/ws/BillingRepository.kt::USAGE_BARS",
|
||||
"data/ws/ConnectorRepository.kt::CONNECTORS_CONNECT",
|
||||
"data/ws/ConnectorRepository.kt::CONNECTORS_LIST",
|
||||
"data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES",
|
||||
"data/ws/HermesWsClient.kt::PROMPT_SUBMIT",
|
||||
"data/ws/HermesWsClient.kt::SESSION_REDIRECT",
|
||||
"data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS",
|
||||
"data/ws/PluginManageRepository.kt::PLUGINS_MANAGE",
|
||||
"data/ws/PluginManageRepository.kt::PLUGINS_MANAGE",
|
||||
"data/ws/PluginRemovalRepository.kt::PLUGINS_MANAGE",
|
||||
"data/ws/ProjectsSource.kt::PROJECTS_LIST",
|
||||
"data/ws/SessionLiveStatusSource.kt::SESSION_ACTIVE_LIST",
|
||||
"data/ws/SubagentRepository.kt::SUBAGENT_LIST",
|
||||
"data/ws/SubagentRepository.kt::SUBAGENT_TAIL",
|
||||
"data/ws/VaultRepository.kt::VAULT_LIST",
|
||||
"data/ws/VaultRepository.kt::VAULT_LOCK",
|
||||
"data/ws/VaultRepository.kt::VAULT_SOURCES",
|
||||
"data/ws/VaultRepository.kt::VAULT_SOURCE_SET",
|
||||
"data/ws/VaultRepository.kt::VAULT_UNLOCK",
|
||||
"notification/NotificationReplyReceiver.kt::PROMPT_SUBMIT",
|
||||
"notification/NotificationReplyReceiver.kt::SESSION_RESUME",
|
||||
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
|
||||
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
|
||||
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
|
||||
"ui/bots/BotsViewModel.kt::PROFILES_LIST",
|
||||
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
|
||||
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
|
||||
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
|
||||
"ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST",
|
||||
"ui/bots/group/GroupChatViewModel.kt::PROMPT_SUBMIT",
|
||||
"ui/bots/group/GroupChatViewModel.kt::PROMPT_SUBMIT",
|
||||
"ui/bots/group/GroupChatViewModel.kt::SESSION_CREATE",
|
||||
"ui/bots/group/GroupChatViewModel.kt::SESSION_INTERRUPT",
|
||||
"ui/chat/ChatApprovalsDelegate.kt::APPROVAL_PENDING",
|
||||
"ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RECEIVED",
|
||||
"ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RESPOND",
|
||||
"ui/chat/ChatConnectionOperationDelegate.kt::CONNECTION_RESPOND",
|
||||
"ui/chat/ChatConnectionOperationDelegate.kt::CONNECTION_RESPOND",
|
||||
"ui/chat/ChatConnectionOperationDelegate.kt::CONNECTORS_OPERATION_WAKE",
|
||||
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
|
||||
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
|
||||
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
|
||||
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
|
||||
"ui/chat/ChatViewModel.kt::COMMANDS_CATALOG",
|
||||
"ui/chat/ChatViewModel.kt::COMMAND_DISPATCH",
|
||||
"ui/chat/ChatViewModel.kt::COMMAND_DISPATCH",
|
||||
"ui/chat/ChatViewModel.kt::CONFIG_GET",
|
||||
"ui/chat/ChatViewModel.kt::CONFIG_SET",
|
||||
"ui/chat/ChatViewModel.kt::FILE_ATTACH",
|
||||
"ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES",
|
||||
"ui/chat/ChatViewModel.kt::PROMPT_BTW",
|
||||
"ui/chat/ChatViewModel.kt::SESSION_BRANCH",
|
||||
"ui/chat/ChatViewModel.kt::SESSION_BRANCH_WHOLE",
|
||||
"ui/chat/ChatViewModel.kt::SESSION_COMPRESS",
|
||||
"ui/chat/ChatViewModel.kt::SESSION_CONTEXT_BREAKDOWN",
|
||||
"ui/chat/ChatViewModel.kt::SESSION_CREATE",
|
||||
"ui/chat/ChatViewModel.kt::SESSION_INTERRUPT",
|
||||
"ui/chat/ChatViewModel.kt::SESSION_INTERRUPT",
|
||||
"ui/chat/ChatViewModel.kt::SESSION_INTERRUPT",
|
||||
"ui/chat/ChatViewModel.kt::SESSION_INTERRUPT",
|
||||
"ui/chat/ChatViewModel.kt::SESSION_LIST",
|
||||
"ui/chat/ChatViewModel.kt::SESSION_RESUME",
|
||||
"ui/chat/ChatViewModel.kt::SESSION_STEER",
|
||||
"ui/chat/ChatViewModel.kt::SESSION_USAGE",
|
||||
"ui/chat/ChatViewModel.kt::SLASH_EXEC",
|
||||
"ui/process/ProcessesViewModel.kt::PROCESS_KILL",
|
||||
"ui/process/ProcessesViewModel.kt::PROCESS_LIST",
|
||||
)
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.ws.GatewayContract
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ParamsDescriptorCheckTest {
|
||||
@Serializable
|
||||
private data class ValidParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("last_seen") val lastSeen: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ExtraFieldParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("since_seq") val sinceSeq: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class MissingRequiredParams(
|
||||
@SerialName("last_seen") val lastSeen: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class DefaultOnRequiredParams(
|
||||
@SerialName("session_id") val sessionId: String = "default_id",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class NullableRequiredParams(
|
||||
@SerialName("session_id") val sessionId: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class DeliverMissingProfileParams(
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun correctClassPasses() {
|
||||
val problems =
|
||||
GatewayContract.paramsDescriptorProblems(
|
||||
"session.events.since",
|
||||
ValidParams.serializer().descriptor,
|
||||
)
|
||||
assertTrue(problems.joinToString("\n"), problems.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extraFieldIsRejected() {
|
||||
val problems =
|
||||
GatewayContract.paramsDescriptorProblems(
|
||||
"session.events.since",
|
||||
ExtraFieldParams.serializer().descriptor,
|
||||
)
|
||||
assertEquals(1, problems.count { "unknown param 'since_seq'" in it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingRequiredFieldIsRejected() {
|
||||
val problems =
|
||||
GatewayContract.paramsDescriptorProblems(
|
||||
"session.events.since",
|
||||
MissingRequiredParams.serializer().descriptor,
|
||||
)
|
||||
assertEquals(1, problems.count { "missing required param 'session_id'" in it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requiredFieldWithDefaultValueIsRejected() {
|
||||
val problems =
|
||||
GatewayContract.paramsDescriptorProblems(
|
||||
"session.events.since",
|
||||
DefaultOnRequiredParams.serializer().descriptor,
|
||||
)
|
||||
assertEquals(1, problems.count { "required param 'session_id' must not be optional" in it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requiredFieldTypedNullableIsRejected() {
|
||||
val problems =
|
||||
GatewayContract.paramsDescriptorProblems(
|
||||
"session.events.since",
|
||||
NullableRequiredParams.serializer().descriptor,
|
||||
)
|
||||
assertEquals(
|
||||
1,
|
||||
problems.count { "required param 'session_id' must not be nullable unless schema allows null" in it },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun omittingProfilePassesEvenWhenRequiredBySchema() {
|
||||
// bot_relay.deliver has required: ["profile", "message"]
|
||||
val problems =
|
||||
GatewayContract.paramsDescriptorProblems(
|
||||
"bot_relay.deliver",
|
||||
DeliverMissingProfileParams.serializer().descriptor,
|
||||
)
|
||||
assertTrue(problems.joinToString("\n"), problems.isEmpty())
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.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.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionEventsSinceTypesTest {
|
||||
@Test
|
||||
fun paramsWithLastSeenEncodesToExpectedJsonObject() {
|
||||
val params = SessionEventsSinceParams(sessionId = "s1", lastSeen = 7)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionEventsSinceParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
put("last_seen", 7)
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paramsWithNullLastSeenEncodesToSessionIdOnly() {
|
||||
val params = SessionEventsSinceParams(sessionId = "s1", lastSeen = null)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionEventsSinceParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultDecodesFullRealisticPayloadIgnoringUnknownKeys() {
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"events",
|
||||
kotlinx.serialization.json.buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "message.token")
|
||||
put("seq", 1)
|
||||
put("session_id", "s1")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
put("latest_seq", 42)
|
||||
put("truncated", false)
|
||||
put("epoch", "ep-999")
|
||||
// Extra unknown keys from server that must be ignored
|
||||
put("count", 1)
|
||||
put(
|
||||
"open_requests",
|
||||
kotlinx.serialization.json.buildJsonArray {
|
||||
add(JsonPrimitive("req-1"))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionEventsSinceResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertEquals("ep-999", decoded.epoch)
|
||||
assertEquals(42, decoded.latestSeq)
|
||||
assertEquals(false, decoded.truncated)
|
||||
assertEquals(1, decoded.events?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultDecodesEmptyObjectToAllNullDefaults() {
|
||||
val payload = buildJsonObject {}
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionEventsSinceResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertNull(decoded.events)
|
||||
assertNull(decoded.latestSeq)
|
||||
assertNull(decoded.truncated)
|
||||
assertNull(decoded.epoch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultToleratesTruncatedAbsent() {
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put("latest_seq", 10)
|
||||
put("epoch", "epoch-1")
|
||||
}
|
||||
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionEventsSinceResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertNull(decoded.truncated)
|
||||
assertEquals(10, decoded.latestSeq)
|
||||
assertEquals("epoch-1", decoded.epoch)
|
||||
assertNull(decoded.events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rpcMethodsRegistrationMatchesContract() {
|
||||
assertEquals("session.events.since", RpcMethods.SESSION_EVENTS_SINCE.name)
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_EVENTS_SINCE))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user