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
@@ -382,6 +382,11 @@ object AuthManager {
|
|||||||
ActiveSessionHolder.clear()
|
ActiveSessionHolder.clear()
|
||||||
}
|
}
|
||||||
serverStore.update { it.copy(selectedProfileId = id) }
|
serverStore.update { it.copy(selectedProfileId = id) }
|
||||||
|
// Keep contextFlow truthful: the base URL resolves per selected
|
||||||
|
// profile, so a connection-profile switch must re-emit the NEW
|
||||||
|
// server's URL (previously stale — reactive consumers saw the old
|
||||||
|
// server's URL after a switch).
|
||||||
|
_baseUrlFlow.value = serverStore.getLatestState().resolvedBaseUrl
|
||||||
_selectedProfileFlow.value = id
|
_selectedProfileFlow.value = id
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
tokenInitialized = false
|
tokenInitialized = false
|
||||||
@@ -416,7 +421,17 @@ object AuthManager {
|
|||||||
private fun normalizedProfileId(profileId: String?): String =
|
private fun normalizedProfileId(profileId: String?): String =
|
||||||
profileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
|
profileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
|
||||||
|
|
||||||
private suspend fun syncCookieStoreForProfile(profileId: String?) {
|
/**
|
||||||
|
* Swaps the cookie jar's ACTIVE store to the given profile's. Idempotent
|
||||||
|
* (no-op when the store is already active). Suspends because switching
|
||||||
|
* store reads/writes the encrypted prefs.
|
||||||
|
*
|
||||||
|
* Internal so the switch coordinator can AWAIT the swap before re-dialing
|
||||||
|
* the WebSocket — a dial that races it mints the WS ticket with the
|
||||||
|
* PREVIOUS server's cookie → 401 → dead socket (split-brain follow-up,
|
||||||
|
* reproduced live 2026-08-12).
|
||||||
|
*/
|
||||||
|
internal suspend fun syncCookieStoreForProfile(profileId: String?) {
|
||||||
if (!CookieManager.isInitialized()) return
|
if (!CookieManager.isInitialized()) return
|
||||||
val normalizedId = normalizedProfileId(profileId)
|
val normalizedId = normalizedProfileId(profileId)
|
||||||
if (CookieManager.cookieJar.currentServer() != normalizedId) {
|
if (CookieManager.cookieJar.currentServer() != normalizedId) {
|
||||||
@@ -456,6 +471,7 @@ object AuthManager {
|
|||||||
appScope = null
|
appScope = null
|
||||||
}
|
}
|
||||||
_activeProfileId.value = null
|
_activeProfileId.value = null
|
||||||
|
_baseUrlFlow.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getToken(): String? {
|
fun getToken(): String? {
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ object ProfileSwitchCoordinator {
|
|||||||
private val _switched = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
private val _switched = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
val switched: SharedFlow<String> = _switched.asSharedFlow()
|
val switched: SharedFlow<String> = _switched.asSharedFlow()
|
||||||
|
|
||||||
|
private val _connectionSwitched = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
|
val connectionSwitched: SharedFlow<String> = _connectionSwitched.asSharedFlow()
|
||||||
|
|
||||||
suspend fun switchProfile(name: String): NetworkResult<Unit> {
|
suspend fun switchProfile(name: String): NetworkResult<Unit> {
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -53,4 +56,40 @@ object ProfileSwitchCoordinator {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches the CONNECTION profile — which server the app talks to (e.g.
|
||||||
|
* LAN "default" vs a Tailscale host). Unlike [switchProfile] (which only
|
||||||
|
* re-scopes the SERVER-side Hermes profile over the same socket), this
|
||||||
|
* re-points Retrofit AND re-dials the WebSocket, because the socket stays
|
||||||
|
* glued to the old server otherwise: after a switch every REST tab talks
|
||||||
|
* to the new server while chat keeps streaming from the old gateway
|
||||||
|
* (split-brain reproduced live 2026-08-12 on the hyari emulator).
|
||||||
|
*
|
||||||
|
* Order matters:
|
||||||
|
* 1. Persist the LOCAL selection — the token cache, cookie scope and
|
||||||
|
* [AuthManager.contextFlow] re-home to the new profile.
|
||||||
|
* 2. Rebuild Retrofit so REST targets the new server.
|
||||||
|
* 3. Emit [connectionSwitched] BEFORE the socket re-dial, so chat wipes
|
||||||
|
* its stale conversation first; the re-dialed socket then delivers
|
||||||
|
* gateway.ready → handleGatewayReady auto-creates a FRESH session on
|
||||||
|
* the new server (desktop requestFreshSession parity).
|
||||||
|
* 4. Re-dial the WebSocket off the main thread (the ticket mint does
|
||||||
|
* blocking I/O — NetworkOnMainThreadException otherwise).
|
||||||
|
*/
|
||||||
|
suspend fun switchConnectionProfile(profileId: String?) {
|
||||||
|
AuthManager.setSelectedProfileId(profileId)
|
||||||
|
ApiClient.rebuild()
|
||||||
|
_connectionSwitched.emit(profileId.orEmpty())
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
// The WS ticket mint reads the cookie jar's ACTIVE store; the
|
||||||
|
// selection change swaps that store asynchronously, so a dial
|
||||||
|
// that races it mints with the PREVIOUS server's cookie → 401 →
|
||||||
|
// aborted socket with no retry. Await the swap before dialing
|
||||||
|
// (idempotent no-op when it already landed).
|
||||||
|
AuthManager.syncCookieStoreForProfile(profileId)
|
||||||
|
HermesWsClient.disconnect()
|
||||||
|
HermesWsClient.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -576,6 +576,16 @@ class ChatViewModel(
|
|||||||
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Same wipe when the CONNECTION profile changes (different server):
|
||||||
|
// without it, gateway.ready on the re-dialed socket tries to resume
|
||||||
|
// the OLD server's session on the NEW server and fails (split-brain
|
||||||
|
// after connection-profile switch, reproduced live 2026-08-12).
|
||||||
|
viewModelScope.launch {
|
||||||
|
ProfileSwitchCoordinator.connectionSwitched
|
||||||
|
.collect { _ ->
|
||||||
|
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Slash-command usage ranking (issue #865): mirror the local usage
|
// Slash-command usage ranking (issue #865): mirror the local usage
|
||||||
// counts into state so the autocomplete can surface most-used
|
// counts into state so the autocomplete can surface most-used
|
||||||
// commands first. Best-effort — the store never throws.
|
// commands first. Best-effort — the store never throws.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.CleartextPolicy
|
|||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
|
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.theme.ThemePreference
|
import com.m57.hermescontrol.theme.ThemePreference
|
||||||
import com.m57.hermescontrol.theme.ThemePreset
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
@@ -103,9 +104,13 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun selectProfile(profileId: String?) {
|
fun selectProfile(profileId: String?) {
|
||||||
AuthManager.setSelectedProfileId(profileId)
|
viewModelScope.launch(ioDispatcher) {
|
||||||
viewModelScope.launch(ioDispatcher) { loadSettings() }
|
// The coordinator re-homes EVERYTHING: selection + Retrofit +
|
||||||
ApiClient.rebuild()
|
// WebSocket re-dial, so chat doesn't stay glued to the previous
|
||||||
|
// server (issue: split-brain after connection-profile switch).
|
||||||
|
ProfileSwitchCoordinator.switchConnectionProfile(profileId)
|
||||||
|
loadSettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteProfile(profileId: String) {
|
fun deleteProfile(profileId: String) {
|
||||||
@@ -114,14 +119,25 @@ class SettingsViewModel(
|
|||||||
AuthManager.setProfileToken(profileId, null)
|
AuthManager.setProfileToken(profileId, null)
|
||||||
// Never leave selection null (issue #478): if the deleted profile was selected,
|
// Never leave selection null (issue #478): if the deleted profile was selected,
|
||||||
// fall back to the default profile instead of clearing selection.
|
// fall back to the default profile instead of clearing selection.
|
||||||
if (AuthManager.getSelectedProfileId() == profileId) {
|
val reselectDefault =
|
||||||
if (updatedProfiles.none { it.id == AuthManager.DEFAULT_PROFILE_ID }) {
|
if (AuthManager.getSelectedProfileId() == profileId) {
|
||||||
AuthManager.ensureDefaultProfile()
|
if (updatedProfiles.none { it.id == AuthManager.DEFAULT_PROFILE_ID }) {
|
||||||
|
AuthManager.ensureDefaultProfile()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
if (reselectDefault) {
|
||||||
|
// Same re-home as a manual switch: the WebSocket must leave
|
||||||
|
// the deleted server too, or chat keeps its stale socket.
|
||||||
|
ProfileSwitchCoordinator.switchConnectionProfile(AuthManager.DEFAULT_PROFILE_ID)
|
||||||
|
} else {
|
||||||
|
ApiClient.rebuild()
|
||||||
|
}
|
||||||
|
loadSettings()
|
||||||
}
|
}
|
||||||
viewModelScope.launch(ioDispatcher) { loadSettings() }
|
|
||||||
ApiClient.rebuild()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Profile add/edit dialog ──────────────────────────────────────────
|
// ── Profile add/edit dialog ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -141,6 +141,32 @@ class AuthManagerTest {
|
|||||||
assertEquals(9090, AuthManager.getPort())
|
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
|
@Test
|
||||||
fun testGetAndSetAutoReconnect() {
|
fun testGetAndSetAutoReconnect() {
|
||||||
AuthManager.setAutoReconnect(false)
|
AuthManager.setAutoReconnect(false)
|
||||||
|
|||||||
@@ -113,5 +113,48 @@ class ProfileSwitchCoordinatorTest {
|
|||||||
verify(exactly = 0) { HermesWsClient.connect() }
|
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))
|
private fun <T> errorResponse(code: Int): Response<T> = Response.error(code, "{}".toResponseBody(null))
|
||||||
}
|
}
|
||||||
@@ -106,6 +106,7 @@ class ChatUpdateCommandTest {
|
|||||||
// collectors park harmlessly.
|
// collectors park harmlessly.
|
||||||
mockkObject(ProfileSwitchCoordinator)
|
mockkObject(ProfileSwitchCoordinator)
|
||||||
every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow<String>()
|
every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow<String>()
|
||||||
|
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
||||||
|
|
||||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||||
reqCount++
|
reqCount++
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ class ChatViewModelTest {
|
|||||||
every { AuthManager.getSelectedProfileId() } returns null
|
every { AuthManager.getSelectedProfileId() } returns null
|
||||||
mockkObject(ProfileSwitchCoordinator)
|
mockkObject(ProfileSwitchCoordinator)
|
||||||
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
|
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
|
||||||
|
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||||
every { AuthManager.isAutoReconnect() } returns false
|
every { AuthManager.isAutoReconnect() } returns false
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class SlashCommandDispatchRpcTest {
|
|||||||
// pattern) so the collectors park harmlessly.
|
// pattern) so the collectors park harmlessly.
|
||||||
mockkObject(ProfileSwitchCoordinator)
|
mockkObject(ProfileSwitchCoordinator)
|
||||||
every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow<String>()
|
every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow<String>()
|
||||||
|
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
||||||
|
|
||||||
app = mockk(relaxed = true)
|
app = mockk(relaxed = true)
|
||||||
fakeRepo = FakeChatPersistenceRepository()
|
fakeRepo = FakeChatPersistenceRepository()
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package com.m57.hermescontrol.ui.settings
|
|||||||
import com.m57.hermescontrol.data.config.ConnectionProfile
|
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
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.ThemePreference
|
||||||
import com.m57.hermescontrol.theme.ThemePreset
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockkObject
|
import io.mockk.mockkObject
|
||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
@@ -84,7 +87,15 @@ class SettingsViewModelTest {
|
|||||||
every { AuthManager.getProfileToken(any()) } returns null
|
every { AuthManager.getProfileToken(any()) } returns null
|
||||||
every { AuthManager.ensureDefaultProfile() } returns Unit
|
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
|
every { ApiClient.rebuild() } returns Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,8 +169,7 @@ class SettingsViewModelTest {
|
|||||||
viewModel.selectProfile("prof-2")
|
viewModel.selectProfile("prof-2")
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
verify { AuthManager.setSelectedProfileId("prof-2") }
|
coVerify { ProfileSwitchCoordinator.switchConnectionProfile("prof-2") }
|
||||||
verify { ApiClient.rebuild() }
|
|
||||||
assertEquals("prof-2", viewModel.uiState.value.selectedProfileId)
|
assertEquals("prof-2", viewModel.uiState.value.selectedProfileId)
|
||||||
assertEquals("Home", viewModel.uiState.value.renameProfileName)
|
assertEquals("Home", viewModel.uiState.value.renameProfileName)
|
||||||
}
|
}
|
||||||
@@ -174,8 +184,7 @@ class SettingsViewModelTest {
|
|||||||
viewModel.selectProfile(AuthManager.DEFAULT_PROFILE_ID)
|
viewModel.selectProfile(AuthManager.DEFAULT_PROFILE_ID)
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
verify { AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) }
|
coVerify { ProfileSwitchCoordinator.switchConnectionProfile(AuthManager.DEFAULT_PROFILE_ID) }
|
||||||
verify { ApiClient.rebuild() }
|
|
||||||
assertEquals(AuthManager.DEFAULT_PROFILE_ID, viewModel.uiState.value.selectedProfileId)
|
assertEquals(AuthManager.DEFAULT_PROFILE_ID, viewModel.uiState.value.selectedProfileId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,11 +196,13 @@ class SettingsViewModelTest {
|
|||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
viewModel.deleteProfile("prof-1")
|
viewModel.deleteProfile("prof-1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
verify { AuthManager.saveConnectionProfiles(any()) }
|
verify { AuthManager.saveConnectionProfiles(any()) }
|
||||||
verify { AuthManager.setProfileToken("prof-1", null) }
|
verify { AuthManager.setProfileToken("prof-1", null) }
|
||||||
verify { AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) }
|
// The deleted profile was selected — the fallback switch re-homes
|
||||||
verify { ApiClient.rebuild() }
|
// everything (selection + Retrofit + WebSocket) to default.
|
||||||
|
coVerify { ProfileSwitchCoordinator.switchConnectionProfile(AuthManager.DEFAULT_PROFILE_ID) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -202,11 +213,13 @@ class SettingsViewModelTest {
|
|||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
viewModel.deleteProfile("prof-2")
|
viewModel.deleteProfile("prof-2")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
verify { AuthManager.saveConnectionProfiles(any()) }
|
verify { AuthManager.saveConnectionProfiles(any()) }
|
||||||
verify { AuthManager.setProfileToken("prof-2", null) }
|
verify { AuthManager.setProfileToken("prof-2", null) }
|
||||||
// Selected profile (prof-1) was NOT deleted — should NOT change selection
|
// Selected profile (prof-1) was NOT deleted — should NOT change selection
|
||||||
verify(exactly = 0) { AuthManager.setSelectedProfileId(any()) }
|
verify(exactly = 0) { AuthManager.setSelectedProfileId(any()) }
|
||||||
|
coVerify(exactly = 0) { ProfileSwitchCoordinator.switchConnectionProfile(any()) }
|
||||||
verify { ApiClient.rebuild() }
|
verify { ApiClient.rebuild() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user