9 files changed
+1377
-51
No files matched your search
@@ -10,6 +10,9 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
|
|||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.remote.await
|
import com.m57.hermescontrol.data.remote.await
|
||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
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.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -821,6 +824,34 @@ object HermesWsClient {
|
|||||||
return deferred
|
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 <P, R> call(
|
||||||
|
method: RpcMethod<P, R>,
|
||||||
|
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. */
|
/** Complete (or fail) a single pending call and cancel its timer. */
|
||||||
private fun resolvePending(
|
private fun resolvePending(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -1093,63 +1124,55 @@ object HermesWsClient {
|
|||||||
for (sid in sessionsToReplay) {
|
for (sid in sessionsToReplay) {
|
||||||
val lastSeen = lastSeenSeq[sid] ?: continue
|
val lastSeen = lastSeenSeq[sid] ?: continue
|
||||||
try {
|
try {
|
||||||
val deferred =
|
val res =
|
||||||
request(
|
call(
|
||||||
method = WsMethods.SESSION_EVENTS_SINCE,
|
RpcMethods.SESSION_EVENTS_SINCE,
|
||||||
params = mapOf("session_id" to sid, "last_seen" to lastSeen),
|
SessionEventsSinceParams(sessionId = sid, lastSeen = lastSeen),
|
||||||
timeoutMs = 10_000L,
|
timeoutMs = 10_000L,
|
||||||
)
|
)
|
||||||
val result = deferred.await()
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
val epoch = res.epoch
|
||||||
val resultMap =
|
val epochChanged =
|
||||||
when (result) {
|
!epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch
|
||||||
is JsonElement -> result.toAny() as? Map<String, Any?>
|
val truncated = res.truncated == true
|
||||||
is Map<*, *> -> result as? Map<String, Any?>
|
val latestSeq = res.latestSeq
|
||||||
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()
|
|
||||||
|
|
||||||
if (epochChanged) replayEpoch = epoch
|
if (epochChanged) replayEpoch = epoch
|
||||||
|
|
||||||
if (epochChanged || truncated) {
|
if (epochChanged || truncated) {
|
||||||
// Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted).
|
// Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted).
|
||||||
// Partial replay would silently hole the transcript — fast-forward watermark
|
// Partial replay would silently hole the transcript — fast-forward watermark
|
||||||
// and request full transcript resync.
|
// and request full transcript resync.
|
||||||
if (epochChanged) lastSeenSeq.clear()
|
if (epochChanged) lastSeenSeq.clear()
|
||||||
if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq
|
if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq
|
||||||
parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid))
|
parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val eventsList = (resultMap["events"] as? List<*>)?.filterIsInstance<Map<String, Any?>>()
|
val eventsList = res.events
|
||||||
if (eventsList != null) {
|
if (eventsList != null) {
|
||||||
for (eventMap in eventsList) {
|
for (element in eventsList) {
|
||||||
val eventSeq = (eventMap["seq"] as? Number)?.toInt()
|
@Suppress("UNCHECKED_CAST")
|
||||||
val eventSid = (eventMap["session_id"] as? String) ?: sid
|
val eventMap = (element as? JsonObject)?.toAny() as? Map<String, Any?> ?: continue
|
||||||
if (eventSeq != null && eventSid.isNotEmpty()) {
|
val eventSeq = (eventMap["seq"] as? Number)?.toInt()
|
||||||
val prev = lastSeenSeq[eventSid] ?: 0
|
val eventSid = (eventMap["session_id"] as? String) ?: sid
|
||||||
if (eventSeq <= prev) continue
|
if (eventSeq != null && eventSid.isNotEmpty()) {
|
||||||
lastSeenSeq[eventSid] = eventSeq
|
val prev = lastSeenSeq[eventSid] ?: 0
|
||||||
}
|
if (eventSeq <= prev) continue
|
||||||
val parsedEvent = EventParser.parseParams(eventMap)
|
lastSeenSeq[eventSid] = eventSeq
|
||||||
val finalEvent =
|
|
||||||
if (parsedEvent is WsEvent.MessageComplete) {
|
|
||||||
parsedEvent.copy(
|
|
||||||
storedSessionId =
|
|
||||||
ActiveSessionHolder.resolveStoredSessionId(
|
|
||||||
parsedEvent.sessionId,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
parsedEvent
|
|
||||||
}
|
|
||||||
parsedEvents.tryEmit(finalEvent)
|
|
||||||
}
|
}
|
||||||
|
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) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
|
||||||
|
class RpcMethod<P, R>(
|
||||||
|
val name: String,
|
||||||
|
val params: KSerializer<P>,
|
||||||
|
val result: KSerializer<R>,
|
||||||
|
)
|
||||||
|
|
||||||
|
object RpcMethods {
|
||||||
|
val SESSION_EVENTS_SINCE: RpcMethod<SessionEventsSinceParams, SessionEventsSinceResult> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_EVENTS_SINCE,
|
||||||
|
SessionEventsSinceParams.serializer(),
|
||||||
|
SessionEventsSinceResult.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */
|
||||||
|
val all: List<RpcMethod<*, *>> = listOf(SESSION_EVENTS_SINCE)
|
||||||
|
}
|
||||||
@@ -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<JsonElement>? = null,
|
||||||
|
@SerialName("latest_seq") val latestSeq: Int? = null,
|
||||||
|
val truncated: Boolean? = null,
|
||||||
|
val epoch: String? = null,
|
||||||
|
)
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.m57.hermescontrol.data.ws
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.boolean
|
import kotlinx.serialization.json.boolean
|
||||||
import kotlinx.serialization.json.jsonArray
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
@@ -70,6 +72,82 @@ object GatewayContract {
|
|||||||
required.filter { it !in keys }.forEach { problems += "$method: missing required param '$it'" }
|
required.filter { it !in keys }.forEach { problems += "$method: missing required param '$it'" }
|
||||||
return problems
|
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
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
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 {
|
private companion object {
|
||||||
/** Methods ChatViewModel.handleServerRequest routes; keep in sync with its `when`. */
|
/** Methods ChatViewModel.handleServerRequest routes; keep in sync with its `when`. */
|
||||||
val APP_HANDLED_SERVER_REQUESTS =
|
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