From d466414563ccc90235e605b7120de0a723f70eae Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:35:10 +0300 Subject: [PATCH] refactor(#1375): typed RPC foundation and session.events.since (#1380) --- .../hermescontrol/data/ws/HermesWsClient.kt | 125 ++-- .../data/ws/contract/RpcMethod.kt | 22 + .../data/ws/contract/SessionEventsSince.kt | 20 + .../hermescontrol/data/ws/GatewayContract.kt | 78 +++ .../data/ws/GatewayContractTest.kt | 15 + .../data/ws/HermesWsClientTypedCallTest.kt | 534 ++++++++++++++++++ .../data/ws/RawRpcParamsRatchetTest.kt | 402 +++++++++++++ .../ws/contract/ParamsDescriptorCheckTest.kt | 106 ++++ .../contract/SessionEventsSinceTypesTest.kt | 126 +++++ 9 files changed, 1377 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSince.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSinceTypesTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 09f3166a..acc2c633 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -10,6 +10,9 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.await import com.m57.hermescontrol.data.session.ActiveSessionHolder +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 kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -821,6 +824,34 @@ object HermesWsClient { return deferred } + /** + * Send a typed JSON-RPC request and await its deserialized result. + * + * Params classes are strict writers, result classes are tolerant readers, and + * profile is never a field in params (it is injected by WsProfileParams). + */ + suspend fun call( + method: RpcMethod, + params: P, + timeoutMs: Long = REQUEST_TIMEOUT_MS, + suppressErrorEvent: Boolean = false, + ): R { + val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params) + require(encoded is JsonObject) { + "RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}" + } + val deferred = request(method.name, encoded, timeoutMs, suppressErrorEvent) + val result = + try { + deferred.await() + } catch (e: CancellationException) { + deferred.cancel(e) + throw e + } + val element = result.toJsonElement() + return OkHttpProvider.json.decodeFromJsonElement(method.result, element) + } + /** Complete (or fail) a single pending call and cancel its timer. */ private fun resolvePending( id: String, @@ -1093,63 +1124,55 @@ object HermesWsClient { for (sid in sessionsToReplay) { val lastSeen = lastSeenSeq[sid] ?: continue try { - val deferred = - request( - method = WsMethods.SESSION_EVENTS_SINCE, - params = mapOf("session_id" to sid, "last_seen" to lastSeen), + val res = + call( + RpcMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams(sessionId = sid, lastSeen = lastSeen), timeoutMs = 10_000L, ) - val result = deferred.await() - @Suppress("UNCHECKED_CAST") - val resultMap = - when (result) { - is JsonElement -> result.toAny() as? Map - is Map<*, *> -> result as? Map - else -> null - } - if (resultMap != null) { - val epoch = resultMap["epoch"] as? String - val epochChanged = - !epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch - val truncated = resultMap["truncated"] == true - val latestSeq = (resultMap["latest_seq"] as? Number)?.toInt() + val epoch = res.epoch + val epochChanged = + !epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch + val truncated = res.truncated == true + val latestSeq = res.latestSeq - if (epochChanged) replayEpoch = epoch + if (epochChanged) replayEpoch = epoch - if (epochChanged || truncated) { - // Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted). - // Partial replay would silently hole the transcript — fast-forward watermark - // and request full transcript resync. - if (epochChanged) lastSeenSeq.clear() - if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq - parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid)) - continue - } - val eventsList = (resultMap["events"] as? List<*>)?.filterIsInstance>() - if (eventsList != null) { - for (eventMap in eventsList) { - val eventSeq = (eventMap["seq"] as? Number)?.toInt() - val eventSid = (eventMap["session_id"] as? String) ?: sid - if (eventSeq != null && eventSid.isNotEmpty()) { - val prev = lastSeenSeq[eventSid] ?: 0 - if (eventSeq <= prev) continue - lastSeenSeq[eventSid] = eventSeq - } - val parsedEvent = EventParser.parseParams(eventMap) - val finalEvent = - if (parsedEvent is WsEvent.MessageComplete) { - parsedEvent.copy( - storedSessionId = - ActiveSessionHolder.resolveStoredSessionId( - parsedEvent.sessionId, - ), - ) - } else { - parsedEvent - } - parsedEvents.tryEmit(finalEvent) + if (epochChanged || truncated) { + // Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted). + // Partial replay would silently hole the transcript — fast-forward watermark + // and request full transcript resync. + if (epochChanged) lastSeenSeq.clear() + if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq + parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid)) + continue + } + val eventsList = res.events + if (eventsList != null) { + for (element in eventsList) { + @Suppress("UNCHECKED_CAST") + val eventMap = (element as? JsonObject)?.toAny() as? Map ?: continue + val eventSeq = (eventMap["seq"] as? Number)?.toInt() + val eventSid = (eventMap["session_id"] as? String) ?: sid + if (eventSeq != null && eventSid.isNotEmpty()) { + val prev = lastSeenSeq[eventSid] ?: 0 + if (eventSeq <= prev) continue + lastSeenSeq[eventSid] = eventSeq } + val parsedEvent = EventParser.parseParams(eventMap) + val finalEvent = + if (parsedEvent is WsEvent.MessageComplete) { + parsedEvent.copy( + storedSessionId = + ActiveSessionHolder.resolveStoredSessionId( + parsedEvent.sessionId, + ), + ) + } else { + parsedEvent + } + parsedEvents.tryEmit(finalEvent) } } } catch (e: Exception) { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt new file mode 100644 index 00000000..4288387d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -0,0 +1,22 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.serialization.KSerializer + +class RpcMethod( + val name: String, + val params: KSerializer

, + val result: KSerializer, +) + +object RpcMethods { + val SESSION_EVENTS_SINCE: RpcMethod = + RpcMethod( + WsMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams.serializer(), + SessionEventsSinceResult.serializer(), + ) + + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ + val all: List> = listOf(SESSION_EVENTS_SINCE) +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSince.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSince.kt new file mode 100644 index 00000000..57cebe0b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSince.kt @@ -0,0 +1,20 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class SessionEventsSinceParams( + @SerialName("session_id") val sessionId: String, + @SerialName("last_seen") val lastSeen: Int? = null, +) // NO profile field: profile is injected by WsProfileParams only. + +// Tolerant reader: every field nullable/defaulted, mirrors what the old untyped parser tolerated. +@Serializable +data class SessionEventsSinceResult( + val events: List? = null, + @SerialName("latest_seq") val latestSeq: Int? = null, + val truncated: Boolean? = null, + val epoch: String? = null, +) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt index 7a4886ac..a5d9530b 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt @@ -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 { + 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() + + 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 + } } /** diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt index 033adc15..be06f9c6 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt @@ -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 = diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt new file mode 100644 index 00000000..9c7e23df --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt @@ -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(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + 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 { + val field = HermesWsClient::class.java.getDeclaredField("pendingCalls") + field.isAccessible = true + return field.get(HermesWsClient) as ConcurrentHashMap + } + + @Test + fun callPutsExactFrameOnTheWire() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(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(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(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(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()) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt new file mode 100644 index 00000000..7f103fc2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -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` 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 = + 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() + 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, + ): List { + val regex = Regex("""WsMethods\.([A-Za-z0-9_]+)""") + val results = mutableListOf() + + 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 = + 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", + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt new file mode 100644 index 00000000..eb4468db --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSinceTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSinceTypesTest.kt new file mode 100644 index 00000000..d4b4b76b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSinceTypesTest.kt @@ -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)) + } +}