diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt new file mode 100644 index 00000000..569aa1f9 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -0,0 +1,217 @@ +package com.m57.hermescontrol.ui.chat + +import android.app.Application +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.HermesDatabase +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Issue #549 — Layer 1+2 linkage: a non-hardcoded slash command must be + * forwarded to the backend via [WsMethods.COMMAND_DISPATCH] with the EXACT + * param shape the gateway expects: { name, arg, session_id }. + * + * Verified against the live backend (hermes-agent tui_gateway/server.py + * @method("command.dispatch")): the gateway reads `params["name"]` and + * `params["arg"]`. A `{"command": ...}` shape (used by an earlier probe) + * resolves to an empty name and returns error 4018. So the mobile MUST send + * name/arg, not command. This test locks that contract. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SlashCommandDispatchRpcTest { + private val testDispatcher = StandardTestDispatcher() + private val mockEventsFlow = MutableSharedFlow(extraBufferCapacity = 64) + private val mockConnectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) + private lateinit var app: Application + private lateinit var fakeRepo: FakeChatPersistenceRepository + private var reqCount = 0 + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + val testMainDispatcher = Dispatchers.Main + reqCount = 0 + + mockkStatic(android.util.Log::class) + every { android.util.Log.d(any(), any()) } returns 0 + every { android.util.Log.i(any(), any()) } returns 0 + every { android.util.Log.w(any(), any()) } returns 0 + every { android.util.Log.e(any(), any(), any()) } returns 0 + + mockkStatic(Dispatchers::class) + every { Dispatchers.IO } returns testDispatcher + every { Dispatchers.Main } returns testMainDispatcher + + mockkObject(AuthManager) + mockkObject(HermesWsClient) + mockkObject(ApiClient) + mockkObject(HermesDatabase) + + app = mockk(relaxed = true) + fakeRepo = FakeChatPersistenceRepository() + + mockConnectionStatus.value = ConnectionStatus.DISCONNECTED + + every { AuthManager.getToken() } returns "test-token" + every { AuthManager.isTypingEffectEnabled() } returns true + every { AuthManager.getTypingEffectDelayMs() } returns 30 + every { AuthManager.isAutoReconnect() } returns false + every { HermesWsClient.events } returns mockEventsFlow + every { HermesWsClient.connectionStatus } returns mockConnectionStatus + every { HermesWsClient.connect() } answers { + mockConnectionStatus.value = ConnectionStatus.CONNECTING + } + every { HermesWsClient.disconnect() } returns Unit + + every { HermesWsClient.send(any(), any(), any()) } answers { + reqCount++ + val id = "req-id-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + every { HermesWsClient.sendMessage(any(), any(), any()) } answers { + reqCount++ + val id = "req-msg-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + private suspend fun TestScope.createViewModelWithSession(): Pair { + val vm = ChatViewModel(app, false, fakeRepo) + advanceUntilIdle() + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + // req-id-3 = session.create (after loadSessions + fetchCommandCatalog) + mockEventsFlow.emit(WsEvent.RpcResult("req-id-3", mapOf("session_id" to "session-xyz"))) + advanceUntilIdle() + return Pair(vm, "session-xyz") + } + + @Test + fun `non-hardcoded slash command forwards name arg session_id to COMMAND_DISPATCH`() = + runTest { + val (vm, sessionId) = createViewModelWithSession() + + val methodSlot = slot() + val paramsSlot = slot>() + var captured = false + every { + HermesWsClient.send(capture(methodSlot), capture(paramsSlot), any()) + } answers { + captured = true + reqCount++ + val id = "req-dispatch-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + // /help is NOT client-special-cased -> RpcDispatch + vm.sendMessage("/help") + advanceUntilIdle() + + assertTrue("expected a COMMAND_DISPATCH send", captured) + assertEquals(WsMethods.COMMAND_DISPATCH, methodSlot.captured) + val params = paramsSlot.captured + assertEquals("help", params["name"]) + assertEquals("", params["arg"]) + assertEquals(sessionId, params["session_id"]) + } + + @Test + fun `slash command with args forwards arg separately`() = + runTest { + val (vm, sessionId) = createViewModelWithSession() + + val methodSlot = slot() + val paramsSlot = slot>() + every { + HermesWsClient.send(capture(methodSlot), capture(paramsSlot), any()) + } answers { + reqCount++ + val id = "req-dispatch-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + vm.sendMessage("/queue do the thing") + advanceUntilIdle() + + assertEquals(WsMethods.COMMAND_DISPATCH, methodSlot.captured) + val params = paramsSlot.captured + assertEquals("queue", params["name"]) + assertEquals("do the thing", params["arg"]) + assertEquals(sessionId, params["session_id"]) + } + + @Test + fun `client-special-cased commands do NOT hit COMMAND_DISPATCH`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val dispatched = mutableListOf() + every { + HermesWsClient.send(any(), any(), any()) + } answers { + // track method via the captured value isn't possible here; re-stub below + reqCount++ + val id = "req-id-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + // Narrow stub to record COMMAND_DISPATCH calls + val seen = mutableListOf() + every { + HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) + } answers { + seen.add("dispatch") + reqCount++ + val id = "req-id-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + vm.sendMessage("/stop") + advanceUntilIdle() + vm.sendMessage("/new") + advanceUntilIdle() + + // /stop -> Interrupt, /new -> NewSession: neither should RpcDispatch. + assertTrue( + "client-handled commands must not be forwarded to the backend", + seen.isEmpty(), + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt new file mode 100644 index 00000000..f4e9442b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt @@ -0,0 +1,63 @@ +package com.m57.hermescontrol.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Issue #549 — Layer 1 audit: the client-side slash dispatcher must route + * exactly the commands it special-cases and forward everything else to the + * backend via RpcDispatch. + * + * These are pure-logic assertions (no Android deps) covering every branch the + * issue calls out: + * - /stop, /interrupt -> Interrupt + * - /new -> NewSession + * - /NEW (case) -> NewSession + * - "/foo bar" (args) -> RpcDispatch + * - "/" only / empty -> RpcDispatch + */ +class SlashCommandDispatcherTest { + private val dispatcher = SlashCommandDispatcher() + + @Test + fun `stop routes to Interrupt`() { + assertEquals(SlashResult.Interrupt, dispatcher.dispatch("/stop")) + } + + @Test + fun `interrupt routes to Interrupt`() { + assertEquals(SlashResult.Interrupt, dispatcher.dispatch("/interrupt")) + } + + @Test + fun `new routes to NewSession`() { + assertEquals(SlashResult.NewSession, dispatcher.dispatch("/new")) + } + + @Test + fun `NEW uppercase still routes to NewSession`() { + // Dispatcher lower-cases before matching, so case must not matter. + assertEquals(SlashResult.NewSession, dispatcher.dispatch("/NEW")) + assertEquals(SlashResult.NewSession, dispatcher.dispatch("/New")) + } + + @Test + fun `command with args forwards to RpcDispatch`() { + // Anything not /stop, /interrupt, /new goes to the backend. + assertEquals(SlashResult.RpcDispatch, dispatcher.dispatch("/foo bar")) + assertEquals(SlashResult.RpcDispatch, dispatcher.dispatch("/help")) + assertEquals(SlashResult.RpcDispatch, dispatcher.dispatch("/status")) + } + + @Test + fun `slash-only and empty forward to RpcDispatch`() { + // "/" splits to [""], lower-cased "" is not a special case. + assertEquals(SlashResult.RpcDispatch, dispatcher.dispatch("/")) + assertEquals(SlashResult.RpcDispatch, dispatcher.dispatch("")) + } + + @Test + fun `unknown slash command forwards to RpcDispatch`() { + assertEquals(SlashResult.RpcDispatch, dispatcher.dispatch("/definitely-not-real")) + } +}