feat(profiles): phase 2 — atomic switch coordinator (#824)

* feat(profiles): ProfileSwitchCoordinator — atomic re-home on profile switch (phase 2)

* fix(ws): deterministic reconnect backoff in tests (testAutoReconnect flake)

* test(ws): harden testAutoReconnect against CI latency (stub token refresh, generous latches)

* test(profiles): remove static Dispatchers mock from coordinator test — it bled into HermesWsClientTest in full-suite runs

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-06 15:07:09 +03:00
1 parent 398268d105
commit 531219cdb5
8 files changed
+241 -84

No files matched your search

@@ -0,0 +1,116 @@
package com.m57.hermescontrol.data.session
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.ws.HermesWsClient
import io.mockk.Ordering
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
/**
* The switch coordinator is the single atomic profile-switch flow — every
* surface (Profiles screen, future quick-switch) routes through it. These
* tests pin the ORDER of operations, because chat's fresh-session behavior
* depends on the switch broadcast landing BEFORE the socket re-dial.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ProfileSwitchCoordinatorTest {
private lateinit var mockApi: HermesApiService
@Before
fun setUp() {
// NOTE: no mockkStatic(Dispatchers) here — a static Dispatchers mock
// bleeds into later test classes in the same JVM (it hijacks
// Dispatchers.IO for HermesWsClient's reconnect coroutines), which
// deterministically broke HermesWsClientTest.testAutoReconnect in
// full-suite runs. Real Dispatchers.IO is fine: the network layer is
// mocked, so withContext(Dispatchers.IO) hops are instant.
mockkObject(ApiClient)
mockApi = mockk(relaxed = true)
every { ApiClient.hermesApi } returns mockApi
mockkObject(AuthManager)
every { AuthManager.setSelectedProfileId(any()) } returns Unit
mockkObject(HermesWsClient)
every { HermesWsClient.disconnect() } returns Unit
every { HermesWsClient.connect() } returns Unit
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `success flips server then persists selection then re-dials socket`() =
runTest {
coEvery { mockApi.setActiveProfile(any()) } returns Response.success(Unit)
val result = ProfileSwitchCoordinator.switchProfile("meow")
assertTrue(result is NetworkResult.Success)
coVerify { mockApi.setActiveProfile(SetActiveProfileRequest("meow")) }
verify(ordering = Ordering.SEQUENCE) {
AuthManager.setSelectedProfileId("meow")
HermesWsClient.disconnect()
HermesWsClient.connect()
}
}
@Test
fun `success broadcasts the switch so chat wipes before the re-dial`() =
runTest {
coEvery { mockApi.setActiveProfile(any()) } returns Response.success(Unit)
// Subscribe BEFORE the switch — exactly how ChatViewModel does it.
val received = Channel<String>(Channel.UNLIMITED)
backgroundScope.launch {
ProfileSwitchCoordinator.switched.collect { received.send(it) }
}
runCurrent()
ProfileSwitchCoordinator.switchProfile("meow")
runCurrent()
// The broadcast is buffered with capacity 1, so chat observes the
// wipe BEFORE gateway.ready arrives on the re-dialed socket and
// auto-creates the fresh session (desktop requestFreshSession).
assertEquals("meow", received.tryReceive().getOrNull())
}
@Test
fun `failure touches nothing`() =
runTest {
coEvery { mockApi.setActiveProfile(any()) } returns errorResponse(500)
val result = ProfileSwitchCoordinator.switchProfile("meow")
assertTrue(result is NetworkResult.Failure)
verify(exactly = 0) { AuthManager.setSelectedProfileId(any()) }
verify(exactly = 0) { HermesWsClient.disconnect() }
verify(exactly = 0) { HermesWsClient.connect() }
}
private fun <T> errorResponse(code: Int): Response<T> = Response.error(code, "{}".toResponseBody(null))
}
@@ -907,6 +907,15 @@ class HermesWsClientTest {
@Test
fun testAutoReconnect() {
every { AuthManager.isAutoReconnect() } returns true
// Deterministic reconnect: zero backoff removes the real-time race
// that flaked this test on loaded CI runners (1s backoff + fixed
// 5-6s latch windows routinely overshoot under parallel load).
HermesWsClient.setReconnectBackoffForTest(0L)
// The reconnect path refreshes the WS token over the network before
// opening the socket. Stub it so no real HTTP call sits inside the
// test's timing window (CI network latency was the dominant flake).
mockkObject(DashboardSessionTokenRefresher)
every { DashboardSessionTokenRefresher.fetch(any(), any()) } returns null
var serverSocket1: WebSocket? = null
var serverSocket2: WebSocket? = null
@@ -944,8 +953,10 @@ class HermesWsClientTest {
HermesWsClient.connect()
assertTrue("Failed initial connection", connect1Latch.await(5, TimeUnit.SECONDS))
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
assertTrue("Failed initial connection", connect1Latch.await(15, TimeUnit.SECONDS))
runBlocking {
withTimeout(15_000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value)
// Force server to close socket 1 to trigger reconnect
@@ -954,13 +965,17 @@ class HermesWsClientTest {
// Wait for status to become RECONNECTING
runBlocking {
withTimeout(
5000,
15_000,
) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.RECONNECTING } }
}
// 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(6, TimeUnit.SECONDS))
// Wait for the second connection to hit the server. Generous ceiling:
// loaded CI runners routinely stretch short wall-clock windows.
assertTrue("Failed to reconnect", connect2Latch.await(30, TimeUnit.SECONDS))
// Restore production backoff so later tests see the default.
HermesWsClient.setReconnectBackoffForTest(1_000L)
}
// ── TEST-10: WS reconnect state recovery ────────────────────────────
@@ -968,6 +983,9 @@ class HermesWsClientTest {
@Test
fun testBackoffResetsOnSuccessfulConnect() {
every { AuthManager.isAutoReconnect() } returns true
// Pin the initial backoff so this test's reset assertion is
// deterministic regardless of what earlier tests configured.
HermesWsClient.setReconnectBackoffForTest(1_000L)
val serverLatch = CountDownLatch(1)
mockWebServer.enqueue(