fix(profiles): connection-profile switch re-dials the WebSocket (#888)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
546bd17b18
commit
683a24becf
10 files changed
+183
-17
No files matched your search
@@ -141,6 +141,32 @@ class AuthManagerTest {
|
||||
assertEquals(9090, AuthManager.getPort())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSetSelectedProfileId_refreshesBaseUrlFlow() {
|
||||
AuthManager.ensureDefaultProfile()
|
||||
AuthManager.setBaseUrl("http://10.0.0.1:9119/")
|
||||
AuthManager.saveConnectionProfiles(
|
||||
listOf(
|
||||
ConnectionProfile(
|
||||
id = AuthManager.DEFAULT_PROFILE_ID,
|
||||
name = "Default",
|
||||
baseUrl = "http://10.0.0.1:9119/",
|
||||
),
|
||||
ConnectionProfile(
|
||||
id = "prof-2",
|
||||
name = "Home",
|
||||
baseUrl = "http://10.0.0.2:9220/",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
AuthManager.setSelectedProfileId("prof-2")
|
||||
|
||||
// contextFlow consumers must see the NEW server's URL after a
|
||||
// connection-profile switch, not the previous one (split-brain fix).
|
||||
assertEquals("http://10.0.0.2:9220/", AuthManager.baseUrlFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetAndSetAutoReconnect() {
|
||||
AuthManager.setAutoReconnect(false)
|
||||
|
||||
@@ -113,5 +113,48 @@ class ProfileSwitchCoordinatorTest {
|
||||
verify(exactly = 0) { HermesWsClient.connect() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connection switch re-homes selection retrofit and socket in order`() =
|
||||
runTest {
|
||||
every { AuthManager.setSelectedProfileId(any()) } returns Unit
|
||||
every { ApiClient.rebuild() } returns Unit
|
||||
coEvery { AuthManager.syncCookieStoreForProfile(any()) } returns Unit
|
||||
|
||||
ProfileSwitchCoordinator.switchConnectionProfile("prof-2")
|
||||
|
||||
// Load-bearing order: selection persists FIRST (token cache +
|
||||
// cookie scope follow), Retrofit re-points, the cookie store swap
|
||||
// is AWAITED (a racing dial mints the ticket with the previous
|
||||
// server's cookie → 401 → dead socket), then the socket re-dials
|
||||
// — so chat's wipe (via the broadcast) lands before the new
|
||||
// gateway's gateway.ready auto-creates the fresh session.
|
||||
coVerify(ordering = Ordering.SEQUENCE) {
|
||||
AuthManager.setSelectedProfileId("prof-2")
|
||||
ApiClient.rebuild()
|
||||
AuthManager.syncCookieStoreForProfile("prof-2")
|
||||
HermesWsClient.disconnect()
|
||||
HermesWsClient.connect()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connection switch broadcasts so chat wipes before the re-dial`() =
|
||||
runTest {
|
||||
every { AuthManager.setSelectedProfileId(any()) } returns Unit
|
||||
every { ApiClient.rebuild() } returns Unit
|
||||
coEvery { AuthManager.syncCookieStoreForProfile(any()) } returns Unit
|
||||
// Subscribe BEFORE the switch — exactly how ChatViewModel does it.
|
||||
val received = Channel<String>(Channel.UNLIMITED)
|
||||
backgroundScope.launch {
|
||||
ProfileSwitchCoordinator.connectionSwitched.collect { received.send(it) }
|
||||
}
|
||||
runCurrent()
|
||||
|
||||
ProfileSwitchCoordinator.switchConnectionProfile("prof-2")
|
||||
runCurrent()
|
||||
|
||||
assertEquals("prof-2", received.tryReceive().getOrNull())
|
||||
}
|
||||
|
||||
private fun <T> errorResponse(code: Int): Response<T> = Response.error(code, "{}".toResponseBody(null))
|
||||
}
|
||||
@@ -106,6 +106,7 @@ class ChatUpdateCommandTest {
|
||||
// collectors park harmlessly.
|
||||
mockkObject(ProfileSwitchCoordinator)
|
||||
every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow<String>()
|
||||
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
||||
|
||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
|
||||
@@ -126,6 +126,7 @@ class ChatViewModelTest {
|
||||
every { AuthManager.getSelectedProfileId() } returns null
|
||||
mockkObject(ProfileSwitchCoordinator)
|
||||
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
|
||||
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
|
||||
@@ -84,6 +84,7 @@ class SlashCommandDispatchRpcTest {
|
||||
// pattern) so the collectors park harmlessly.
|
||||
mockkObject(ProfileSwitchCoordinator)
|
||||
every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow<String>()
|
||||
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
||||
|
||||
app = mockk(relaxed = true)
|
||||
fakeRepo = FakeChatPersistenceRepository()
|
||||
|
||||
@@ -3,8 +3,11 @@ package com.m57.hermescontrol.ui.settings
|
||||
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
@@ -84,7 +87,15 @@ class SettingsViewModelTest {
|
||||
every { AuthManager.getProfileToken(any()) } returns null
|
||||
every { AuthManager.ensureDefaultProfile() } returns Unit
|
||||
|
||||
// Ensure ApiClient.rebuild() is stubbed (used by selectProfile)
|
||||
// selectProfile/deleteProfile route the switch through the coordinator
|
||||
// (selection + Retrofit + WebSocket re-dial). Mirror the selection
|
||||
// side-effect like the real coordinator's setSelectedProfileId does,
|
||||
// so loadSettings() observes the new selection.
|
||||
mockkObject(ProfileSwitchCoordinator)
|
||||
coEvery { ProfileSwitchCoordinator.switchConnectionProfile(any()) } answers {
|
||||
storedSelectedProfileId = firstArg()
|
||||
Unit
|
||||
}
|
||||
every { ApiClient.rebuild() } returns Unit
|
||||
}
|
||||
|
||||
@@ -158,8 +169,7 @@ class SettingsViewModelTest {
|
||||
viewModel.selectProfile("prof-2")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
verify { AuthManager.setSelectedProfileId("prof-2") }
|
||||
verify { ApiClient.rebuild() }
|
||||
coVerify { ProfileSwitchCoordinator.switchConnectionProfile("prof-2") }
|
||||
assertEquals("prof-2", viewModel.uiState.value.selectedProfileId)
|
||||
assertEquals("Home", viewModel.uiState.value.renameProfileName)
|
||||
}
|
||||
@@ -174,8 +184,7 @@ class SettingsViewModelTest {
|
||||
viewModel.selectProfile(AuthManager.DEFAULT_PROFILE_ID)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
verify { AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) }
|
||||
verify { ApiClient.rebuild() }
|
||||
coVerify { ProfileSwitchCoordinator.switchConnectionProfile(AuthManager.DEFAULT_PROFILE_ID) }
|
||||
assertEquals(AuthManager.DEFAULT_PROFILE_ID, viewModel.uiState.value.selectedProfileId)
|
||||
}
|
||||
|
||||
@@ -187,11 +196,13 @@ class SettingsViewModelTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.deleteProfile("prof-1")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
verify { AuthManager.saveConnectionProfiles(any()) }
|
||||
verify { AuthManager.setProfileToken("prof-1", null) }
|
||||
verify { AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) }
|
||||
verify { ApiClient.rebuild() }
|
||||
// The deleted profile was selected — the fallback switch re-homes
|
||||
// everything (selection + Retrofit + WebSocket) to default.
|
||||
coVerify { ProfileSwitchCoordinator.switchConnectionProfile(AuthManager.DEFAULT_PROFILE_ID) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -202,11 +213,13 @@ class SettingsViewModelTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.deleteProfile("prof-2")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
verify { AuthManager.saveConnectionProfiles(any()) }
|
||||
verify { AuthManager.setProfileToken("prof-2", null) }
|
||||
// Selected profile (prof-1) was NOT deleted — should NOT change selection
|
||||
verify(exactly = 0) { AuthManager.setSelectedProfileId(any()) }
|
||||
coVerify(exactly = 0) { ProfileSwitchCoordinator.switchConnectionProfile(any()) }
|
||||
verify { ApiClient.rebuild() }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user