diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt new file mode 100644 index 00000000..45740e74 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt @@ -0,0 +1,332 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.MutableStateFlow +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.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class HermesWsClientTest { + private lateinit var mockWebServer: MockWebServer + + @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.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 + + // Reset state + val connectedField = HermesWsClient::class.java.getDeclaredField("connected") + connectedField.isAccessible = true + (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + + val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus") + statusField.isAccessible = true + @Suppress("UNCHECKED_CAST") + (statusField.get(HermesWsClient) as MutableStateFlow).value = ConnectionStatus.DISCONNECTED + + HermesWsClient.disconnect() // Ensure it starts clean + } + + @After + fun tearDown() { + HermesWsClient.disconnect() + // Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer + Thread.sleep(100) + try { + mockWebServer.shutdown() + } catch (e: Exception) { + e.printStackTrace() + } + unmockkAll() + } + + @Test + fun testConnectAndSend() { + var serverWebSocket: WebSocket? = null + val serverLatch = CountDownLatch(1) + val messageLatch = CountDownLatch(1) + var receivedMessage: String? = null + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverWebSocket = webSocket + serverLatch.countDown() + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + receivedMessage = text + messageLatch.countDown() + } + }, + ), + ) + + val clientLatch = CountDownLatch(1) + HermesWsClient.onConnected = { + clientLatch.countDown() + } + + HermesWsClient.connect() + assertTrue("Client failed to connect", clientLatch.await(5, TimeUnit.SECONDS)) + assertTrue("Server failed to accept connection", serverLatch.await(5, TimeUnit.SECONDS)) + assertTrue(HermesWsClient.isConnected) + assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value) + + // Send a message + val id = HermesWsClient.send("test_method", mapOf("param" to "value")) + + // Verify message received by server + assertTrue("Message not received", messageLatch.await(5, TimeUnit.SECONDS)) + assertNotNull(receivedMessage) + val msg = receivedMessage ?: "" + assertTrue(msg.contains("test_method")) + assertTrue(msg.contains("value")) + assertTrue(msg.contains(id)) + } + + @Test + fun testReceiveMessage() { + var serverWebSocket: WebSocket? = null + val serverLatch = CountDownLatch(1) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverWebSocket = webSocket + serverLatch.countDown() + } + }, + ), + ) + + val clientLatch = CountDownLatch(1) + HermesWsClient.onConnected = { + clientLatch.countDown() + } + + HermesWsClient.connect() + assertTrue(clientLatch.await(5, TimeUnit.SECONDS)) + assertTrue(serverLatch.await(5, TimeUnit.SECONDS)) + + val eventLatch = CountDownLatch(1) + var receivedEvent: WsEvent? = null + HermesWsClient.onMessage = { event -> + receivedEvent = event + eventLatch.countDown() + } + + // Server sends a message to client + val jsonResponse = + """ + { + "jsonrpc": "2.0", + "id": "1", + "result": "success" + } + """.trimIndent() + serverWebSocket?.send(jsonResponse) + + assertTrue("Event not received", eventLatch.await(5, TimeUnit.SECONDS)) + assertTrue(receivedEvent is WsEvent.RpcResult) + assertEquals("1", (receivedEvent as WsEvent.RpcResult).id) + } + + @Test + fun testDisconnect() { + val serverLatch = CountDownLatch(1) + val closedLatch = CountDownLatch(1) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverLatch.countDown() + } + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + closedLatch.countDown() + } + }, + ), + ) + + val clientLatch = CountDownLatch(1) + val clientDisconnectedLatch = CountDownLatch(1) + HermesWsClient.onConnected = { + clientLatch.countDown() + } + HermesWsClient.onDisconnected = { _ -> + clientDisconnectedLatch.countDown() + } + + HermesWsClient.connect() + assertTrue(clientLatch.await(5, TimeUnit.SECONDS)) + assertTrue(serverLatch.await(5, TimeUnit.SECONDS)) + + HermesWsClient.disconnect() + assertFalse(HermesWsClient.isConnected) + assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value) + + // Verify server received close frame + assertTrue(closedLatch.await(5, TimeUnit.SECONDS)) + } + + @Test + fun testSendMessage() { + var serverWebSocket: WebSocket? = null + val serverLatch = CountDownLatch(1) + val messageLatch = CountDownLatch(1) + var receivedMessage: String? = null + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverWebSocket = webSocket + serverLatch.countDown() + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + receivedMessage = text + messageLatch.countDown() + } + }, + ), + ) + + val clientLatch = CountDownLatch(1) + HermesWsClient.onConnected = { + clientLatch.countDown() + } + + HermesWsClient.connect() + assertTrue("Client failed to connect", clientLatch.await(5, TimeUnit.SECONDS)) + assertTrue("Server failed to accept connection", serverLatch.await(5, TimeUnit.SECONDS)) + + // Use the convenience method + HermesWsClient.sendMessage("test_session_id", "Hello Hermes!") + + // Verify message received by server + assertTrue("Message not received", messageLatch.await(5, TimeUnit.SECONDS)) + assertNotNull(receivedMessage) + val msg = receivedMessage ?: "" + assertTrue(msg.contains(WsMethods.PROMPT_SUBMIT)) + assertTrue(msg.contains("test_session_id")) + assertTrue(msg.contains("Hello Hermes!")) + } + + @Test + fun testAutoReconnect() { + every { AuthManager.isAutoReconnect() } returns true + + var serverSocket1: WebSocket? = null + var serverSocket2: WebSocket? = null + + val connect1Latch = CountDownLatch(1) + val connect2Latch = CountDownLatch(1) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverSocket1 = webSocket + connect1Latch.countDown() + } + }, + ), + ) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverSocket2 = webSocket + connect2Latch.countDown() + } + }, + ), + ) + + val clientConnected1Latch = CountDownLatch(1) + val clientDisconnectedLatch = CountDownLatch(1) + + HermesWsClient.onConnected = { + clientConnected1Latch.countDown() + } + HermesWsClient.onDisconnected = { _ -> + clientDisconnectedLatch.countDown() + } + + HermesWsClient.connect() + + assertTrue("Failed initial connection", connect1Latch.await(5, TimeUnit.SECONDS)) + assertTrue("Client failed initial connection", clientConnected1Latch.await(5, TimeUnit.SECONDS)) + assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value) + + // Force server to close socket 1 to trigger reconnect + serverSocket1?.close(1001, "Server shutting down") + + assertTrue("Client didn't detect disconnect", clientDisconnectedLatch.await(5, TimeUnit.SECONDS)) + assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value) + + // The client should now attempt to reconnect after initial backoff (1000ms) + // Wait for the second connection to hit the server + assertTrue("Failed to reconnect", connect2Latch.await(5, TimeUnit.SECONDS)) + } +}