feat(profiles): first-class profile switching — phase 0 baseline port (#820)
* feat(profiles): make profile switching actually scope the app (issue #781 follow-up) * fix(profiles): inherit connection token when switching to same-dashboard profile (issue #781 follow-up) * fix(chat): reset open session on profile switch + prefill login profile (desktop parity) * fix(ws): inject profile into profile-scoped RPC params so chat follows the selected profile * test(chat): de-poison SlashCommandDispatchRpcTest (drop static Dispatchers mock) mockkStatic(Dispatchers::class) + a stubbed Dispatchers.IO bleeds JVM-wide and breaks later classes in suite order (CI 2026-08-06: COMMAND_DISPATCH captures cross-wired → ComparisonFailure on lower stack PRs). ChatViewModel gains an injected ioDispatcher (38 sites); the slash test constructs the VM with the test dispatcher so the IO stub is unnecessary. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
e56d4c9bfa
commit
75ac0c4790
14 files changed
+579
-56
No files matched your search
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.model.ProfilesResponse
|
||||
import com.m57.hermescontrol.data.model.RenameProfileRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
@@ -20,6 +21,7 @@ import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
@@ -50,6 +52,8 @@ class ProfilesViewModelTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private lateinit var mockApi: HermesApiService
|
||||
private var storedPinnedModels: MutableList<PinnedModel> = mutableListOf()
|
||||
private var storedSelectedProfile: String? = null
|
||||
private var storedProfileToken: String? = null
|
||||
|
||||
private fun stubProfilesLoad() {
|
||||
coEvery { mockApi.getProfiles() } returns
|
||||
@@ -68,10 +72,25 @@ class ProfilesViewModelTest {
|
||||
|
||||
mockkObject(AuthManager)
|
||||
storedPinnedModels = mutableListOf()
|
||||
storedSelectedProfile = null
|
||||
storedProfileToken = null
|
||||
every { AuthManager.getPinnedModels() } answers { storedPinnedModels.toList() }
|
||||
every { AuthManager.savePinnedModels(any()) } answers {
|
||||
storedPinnedModels = firstArg<List<PinnedModel>>().toMutableList()
|
||||
}
|
||||
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfile }
|
||||
every { AuthManager.setSelectedProfileId(any()) } answers {
|
||||
storedSelectedProfile = firstArg<String?>()
|
||||
}
|
||||
every { AuthManager.getToken() } answers { "tok-abc" }
|
||||
every { AuthManager.getProfileToken(any()) } answers { storedProfileToken }
|
||||
every { AuthManager.setProfileToken(any(), any()) } answers {
|
||||
storedProfileToken = secondArg<String?>()
|
||||
}
|
||||
|
||||
mockkObject(HermesWsClient)
|
||||
every { HermesWsClient.disconnect() } returns Unit
|
||||
every { HermesWsClient.connect() } returns Unit
|
||||
|
||||
mockkObject(ApiClient)
|
||||
mockApi = mockk(relaxed = true)
|
||||
@@ -262,4 +281,55 @@ class ProfilesViewModelTest {
|
||||
assertTrue(vm.uiState.value.modelPickerPinned.isEmpty())
|
||||
assertTrue(storedPinnedModels.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectActiveProfile success persists selection and rehomes websocket`() {
|
||||
coEvery { mockApi.setActiveProfile(any()) } returns Response.success(Unit)
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.selectActiveProfile("work")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
// Local selection persisted → ProfileScopeInterceptor now scopes all
|
||||
// management REST calls (?profile=work) to the new profile.
|
||||
assertEquals("work", storedSelectedProfile)
|
||||
// Token inherited from the current connection so switching to a
|
||||
// same-dashboard profile doesn't force a re-login (token_<profile>
|
||||
// is keyed per profile and a freshly-created profile has none).
|
||||
assertEquals("tok-abc", storedProfileToken)
|
||||
// WebSocket re-homed so the live gateway follows the new profile.
|
||||
verify { HermesWsClient.disconnect() }
|
||||
verify { HermesWsClient.connect() }
|
||||
assertTrue(vm.uiState.value.toastMessage!!.contains("Switched to profile work"))
|
||||
coVerify { mockApi.getProfiles() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectActiveProfile keeps existing token for the target profile`() {
|
||||
storedProfileToken = "tok-meow"
|
||||
coEvery { mockApi.setActiveProfile(any()) } returns Response.success(Unit)
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.selectActiveProfile("meow")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
// A profile that already has its own token (e.g. connected elsewhere)
|
||||
// must NOT be clobbered by inheritance.
|
||||
assertEquals("tok-meow", storedProfileToken)
|
||||
assertEquals("meow", storedSelectedProfile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectActiveProfile failure rolls back and keeps local selection`() {
|
||||
coEvery { mockApi.setActiveProfile(any()) } returns errorResponse(500)
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.selectActiveProfile("work")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertNull(storedSelectedProfile)
|
||||
assertNull(storedProfileToken)
|
||||
assertNull(vm.uiState.value.activeProfileName)
|
||||
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to switch profile"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user