diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 4f482d16..64905294 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -41,6 +41,7 @@ import kotlinx.coroutines.runBlocking */ object AuthManager { private const val PREFS_FILE = "hermes_secure_prefs" + private const val KEY_ACTIVE_PROFILE_ID = "active_profile_id" const val DEFAULT_PROFILE_ID = "default" const val DEFAULT_PROFILE_NAME = "Default" @@ -80,6 +81,21 @@ object AuthManager { private val _selectedProfileFlow = MutableStateFlow(null) val selectedProfileFlow: StateFlow = _selectedProfileFlow.asStateFlow() + /** + * Active server-side Hermes profile scope (e.g. "default" / "meow"). + * + * DISTINCT from [selectedProfileId] (which selects a LOCAL connection + * profile — a server + its token). The active profile is the scope + * injected as REST `?profile=` and WS `params.profile`. It deliberately + * does NOT route through [ServerStoreState.selfHealed]: server profile + * ids are not guaranteed to exist in the local connection list, and + * clamping them silently reset the scope to "default" — the live + * profile-switch bug (logcat 2026-08-06: session.create carried + * profile=default right after switching to meow). + */ + private val _activeProfileId = MutableStateFlow(null) + val activeProfileId: StateFlow = _activeProfileId.asStateFlow() + private val _baseUrlFlow = MutableStateFlow("") val baseUrlFlow: StateFlow = _baseUrlFlow.asStateFlow() @@ -146,6 +162,7 @@ object AuthManager { ) migrateLegacyDefaultIfNeeded(p) _tokenFlow.value = getTokenInternal(p) + _activeProfileId.value = p.getString(KEY_ACTIVE_PROFILE_ID, null)?.takeIf { it.isNotBlank() } p } } @@ -374,6 +391,28 @@ object AuthManager { appScope?.launch { syncCookieStoreForProfile(id) } } + /** + * Set the active server-side Hermes profile scope. + * + * Unlike [setSelectedProfileId], this does NOT touch the server store — + * it is prefs-backed and immune to [ServerStoreState.selfHealed], so a + * server profile that has no local [ConnectionProfile] (the normal case) + * survives the switch instead of being clamped to null/default. + * + * NOTE: read the value via [activeProfileId].value — a JVM getter named + * `getActiveProfileId()` would collide with the property's generated + * getter (same name, different return type) and silently resolve to the + * property getter at runtime (ClassCastException: ReadonlyStateFlow -> + * String, 2026-08-06 CI). + */ + fun setActiveProfileId(id: String?) { + val normalized = id?.takeIf { it.isNotBlank() } + _activeProfileId.value = normalized + runCatching { + requirePrefs().edit().putString(KEY_ACTIVE_PROFILE_ID, normalized).apply() + } + } + private fun normalizedProfileId(profileId: String?): String = profileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID @@ -416,6 +455,7 @@ object AuthManager { } appScope = null } + _activeProfileId.value = null } fun getToken(): String? { diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt index 5cb303fb..02d92bd1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt @@ -60,7 +60,7 @@ object ProfileScopeInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val profile = - AuthManager.getSelectedProfileId() + AuthManager.activeProfileId.value ?: return chain.proceed(request) val url = request.url diff --git a/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt b/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt index ac58b056..efa9022a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt @@ -41,10 +41,16 @@ object ProfileSwitchCoordinator { } if (result !is NetworkResult.Success) return result - AuthManager.setSelectedProfileId(name) + AuthManager.setActiveProfileId(name) _switched.emit(name) - HermesWsClient.disconnect() - HermesWsClient.connect() + // The ticket mint inside connect() does blocking network I/O — it must + // run off the main thread or the dial crashes with + // NetworkOnMainThreadException and falls back to the 1s reconnect + // retry (visible in the 2026-08-06 live logcat). + withContext(Dispatchers.IO) { + HermesWsClient.disconnect() + HermesWsClient.connect() + } return result } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsProfileParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsProfileParams.kt index 0e6734aa..f1779e8a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsProfileParams.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsProfileParams.kt @@ -11,7 +11,7 @@ import com.m57.hermescontrol.data.local.AuthManager * (usually "default"), even after the user switches profiles in the app. * * Rules (mirror the REST interceptor contract): - * - If [AuthManager.getSelectedProfileId] is `null` → pass through unchanged + * - If [AuthManager.activeProfileId] is `null` → pass through unchanged * (legacy single-profile behavior). * - If the caller already supplied an explicit `"profile"` key in [params] → * explicit wins; do **not** overwrite. @@ -29,7 +29,7 @@ object WsProfileParams { params: Map, ): Map { // No active profile → legacy single-profile behavior. - val profile = AuthManager.getSelectedProfileId() ?: return params + val profile = AuthManager.activeProfileId.value ?: return params // Only scoped methods get the profile key. if (method !in WsMethods.PROFILE_SCOPED_METHODS) return params diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 230164f9..9a98e9f4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -2263,7 +2263,7 @@ class ChatViewModel( */ fun fetchContextUsage() { val sessionId = _uiState.value.currentSessionId ?: return - val profile = AuthManager.getSelectedProfileId() + val profile = AuthManager.activeProfileId.value viewModelScope.launch(ioDispatcher) { // Denominator fallback: full context window (cheap, public, rarely // changes). The RPC's context_max below overrides it when present. diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt index 3869a806..cc92636b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt @@ -20,6 +20,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.ui.common.ToastHost +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -52,8 +53,9 @@ data class ProfilesUiState( val isSearchingHub: Boolean = false, ) -class ProfilesViewModel : - ViewModel(), +class ProfilesViewModel( + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(ProfilesUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -63,9 +65,9 @@ class ProfilesViewModel : viewModelScope.launch { try { coroutineScope { - val profilesDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getProfiles() } } + val profilesDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getProfiles() } } val activeDeferred = - async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } } + async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } } val profilesResult = profilesDeferred.await() val activeResult = activeDeferred.await() @@ -130,7 +132,7 @@ class ProfilesViewModel : _uiState.update { it.copy(isLoadingSoul = true, selectedSoulContent = null) } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getProfileSoul(profileName) } } when (result) { @@ -161,7 +163,7 @@ class ProfilesViewModel : ) { viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.updateProfileSoul( profileName, @@ -198,7 +200,7 @@ class ProfilesViewModel : ) { viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.updateProfileModel( profileName, @@ -238,7 +240,7 @@ class ProfilesViewModel : viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.cloneProfile( sourceProfileName, @@ -275,7 +277,7 @@ class ProfilesViewModel : viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.updateProfileDescription( profileName, @@ -313,7 +315,7 @@ class ProfilesViewModel : viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.renameProfile( oldName, @@ -347,7 +349,7 @@ class ProfilesViewModel : viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.deleteProfile(name) } } when (result) { @@ -376,7 +378,7 @@ class ProfilesViewModel : _uiState.update { it.copy(isAutoDescribing = true) } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.describeProfileAuto( name, @@ -425,7 +427,7 @@ class ProfilesViewModel : _uiState.update { it.copy(isLoadingSetupCommand = true, setupCommand = null) } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getProfileSetupCommand(name) } } when (result) { @@ -454,7 +456,7 @@ class ProfilesViewModel : _uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getModelOptions() } } when (result) { @@ -502,8 +504,8 @@ class ProfilesViewModel : viewModelScope.launch { try { coroutineScope { - val modelsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getModelOptions() } } - val skillsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getSkills() } } + val modelsDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getModelOptions() } } + val skillsDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getSkills() } } val modelsResult = modelsDeferred.await() val skillsResult = skillsDeferred.await() @@ -545,7 +547,7 @@ class ProfilesViewModel : _uiState.update { it.copy(isSearchingHub = true) } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.searchSkillsHub(query) } } when (result) { @@ -577,7 +579,7 @@ class ProfilesViewModel : _uiState.update { it.copy(isLoading = true) } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.createProfile(request) } } when (result) { diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt index 94ab886e..ddf67591 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt @@ -101,6 +101,11 @@ class AuthManagerTest { @After fun tearDown() { + // Cancel the init() appScope (DataStore collector) so it does not leak + // into later classes — leaked collectors re-touch deleted/recreated + // server_store.json and surface as UncaughtExceptionsBeforeTest + // phantoms in whichever test class runs next. + AuthManager.resetAuthStateForTest() val tempDir = java.io.File(System.getProperty("java.io.tmpdir") ?: "/tmp") val tempFile = java.io.File(tempDir, "server_store.json") if (tempFile.exists()) { @@ -465,4 +470,70 @@ class AuthManagerTest { verify { mockEditor.remove("auth_token") } verify { mockEditor.putBoolean("legacy_default_migrated", true) } } + + // ── Active Hermes profile (server-side scope) ───────────────────────── + + /** + * THE live profile-switch regression (2026-08-06 logcat): the coordinator + * persisted the server profile via setSelectedProfileId, but + * ServerStore.selfHealed() clamps ids that have no local + * ConnectionProfile — "meow" never exists locally, so the scope silently + * fell back to default and the re-dialed socket created the fresh session + * in the WRONG profile. The active profile state is deliberately NOT + * routed through the server store, so no clamp can touch it. + */ + @Test + fun testActiveProfileId_notClampedByLocalConnectionProfiles() { + // The OLD path (server store) clamps ids with no local ConnectionProfile: + // this is the bug the live logcat exposed. Prove the clamp still bites + // there, and that the NEW active-profile state carries the same id. + AuthManager.setSelectedProfileId("meow") + assertNull(AuthManager.getSelectedProfileId()) + + AuthManager.setActiveProfileId("meow") + assertEquals("meow", AuthManager.activeProfileId.value) + // Connection-level selection is untouched by the scope change — the + // clamp already nulled it, and setActiveProfileId does not revive it. + assertNull(AuthManager.getSelectedProfileId()) + } + + @Test + fun testActiveProfileId_persistedToPrefs() { + AuthManager.setActiveProfileId("meow") + + verify { mockEditor.putString("active_profile_id", "meow") } + } + + @Test + fun testActiveProfileId_restoredFromPrefsAfterRestart() { + every { mockPrefs.getString("active_profile_id", null) } returns "meow" + + AuthManager.resetAuthStateForTest() + AuthManager.init(testContext) + kotlinx.coroutines.runBlocking { + val field = AuthManager::class.java.getDeclaredField("prefsDeferred") + field.isAccessible = true + (field.get(AuthManager) as? kotlinx.coroutines.Deferred<*>)?.await() + } + + assertEquals("meow", AuthManager.activeProfileId.value) + + // Cancel the second init's appScope so its DataStore collector does + // not leak into later test classes (leaked scopes re-touch mocked + // prefs/DataStore state and break subsequent mockkObject(AuthManager) + // classes — the "Missing mocked calls" / UncaughtExceptionsBeforeTest + // suite-order failures). + AuthManager.resetAuthStateForTest() + } + + @Test + fun testActiveProfileId_blankAndNullClear() { + AuthManager.setActiveProfileId("meow") + AuthManager.setActiveProfileId("") + assertNull(AuthManager.activeProfileId.value) + + AuthManager.setActiveProfileId("meow") + AuthManager.setActiveProfileId(null) + assertNull(AuthManager.activeProfileId.value) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt index 751c1d6b..8a8869b6 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt @@ -102,6 +102,11 @@ class Issue647ProfileUrlTest { @After fun tearDown() { + // Cancel the init() appScope (DataStore collector) so it does not leak + // into later classes — leaked collectors re-touch deleted/recreated + // server_store.json and surface as UncaughtExceptionsBeforeTest + // phantoms in whichever test class runs next. + AuthManager.resetAuthStateForTest() val tempDir = java.io.File(System.getProperty("java.io.tmpdir") ?: "/tmp") val tempFile = java.io.File(tempDir, "server_store.json") if (tempFile.exists()) tempFile.delete() diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt index 012650d2..f4da2244 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt @@ -1,9 +1,6 @@ package com.m57.hermescontrol.data.remote import com.m57.hermescontrol.data.local.AuthManager -import io.mockk.every -import io.mockk.mockkObject -import io.mockk.unmockkAll import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request @@ -20,7 +17,12 @@ class ProfileScopeInterceptorTest { @Before fun setUp() { - mockkObject(AuthManager) + // NOTE: deliberately NO mockkObject(AuthManager) — mocking the object + // from a class that runs after real AuthManager init (AuthManagerTest + // re-inits + leaks scopes) fails with "Missing mocked calls inside + // every{}" in suite order. The real prefs-backed setActiveProfileId is + // runCatching-safe pre-init, so tests use the REAL state instead. + AuthManager.resetAuthStateForTest() server = MockWebServer() server.start() } @@ -28,12 +30,12 @@ class ProfileScopeInterceptorTest { @After fun tearDown() { server.shutdown() - unmockkAll() + AuthManager.resetAuthStateForTest() } - /** Builds a real client with the interceptor + a stubbed AuthManager profile. */ + /** Builds a real client with the interceptor + a real active profile state. */ private fun clientFor(profile: String?): OkHttpClient { - every { AuthManager.getSelectedProfileId() } returns profile + AuthManager.setActiveProfileId(profile) return OkHttpClient .Builder() .addInterceptor(ProfileScopeInterceptor) @@ -64,7 +66,7 @@ class ProfileScopeInterceptorTest { @Test fun explicitProfileParam_wins() { - every { AuthManager.getSelectedProfileId() } returns "work" + AuthManager.setActiveProfileId("work") val client = OkHttpClient.Builder().addInterceptor(ProfileScopeInterceptor).build() server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) val req = @@ -80,7 +82,7 @@ class ProfileScopeInterceptorTest { @Test fun nonScopedEndpoint_untouched() { // Pairing is machine-global (not profile-scoped on the backend). - every { AuthManager.getSelectedProfileId() } returns "work" + AuthManager.setActiveProfileId("work") val client = OkHttpClient.Builder().addInterceptor(ProfileScopeInterceptor).build() server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) val req = Request.Builder().url(server.url("api/pairing")).build() @@ -120,7 +122,7 @@ class ProfileScopeInterceptorTest { fun lookalikePath_notScoped() { // Sourcery review (PR #540): `startsWith` must not match non-segment // suffixes like /api/statusXYZ or /api/gatewayExtra. - every { AuthManager.getSelectedProfileId() } returns "work" + AuthManager.setActiveProfileId("work") val client = OkHttpClient.Builder().addInterceptor(ProfileScopeInterceptor).build() server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) val req = Request.Builder().url(server.url("api/statusXYZ")).build() @@ -134,7 +136,7 @@ class ProfileScopeInterceptorTest { fun scopedSubPath_isScoped() { // A scoped prefix with a trailing segment (/api/status/health) MUST // still receive the profile param. - every { AuthManager.getSelectedProfileId() } returns "work" + AuthManager.setActiveProfileId("work") val client = OkHttpClient.Builder().addInterceptor(ProfileScopeInterceptor).build() server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) val req = Request.Builder().url(server.url("api/status/health")).build() diff --git a/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt index 32f9bbb5..27ef0ade 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt @@ -51,7 +51,7 @@ class ProfileSwitchCoordinatorTest { every { ApiClient.hermesApi } returns mockApi mockkObject(AuthManager) - every { AuthManager.setSelectedProfileId(any()) } returns Unit + every { AuthManager.setActiveProfileId(any()) } returns Unit mockkObject(HermesWsClient) every { HermesWsClient.disconnect() } returns Unit @@ -73,7 +73,7 @@ class ProfileSwitchCoordinatorTest { assertTrue(result is NetworkResult.Success) coVerify { mockApi.setActiveProfile(SetActiveProfileRequest("meow")) } verify(ordering = Ordering.SEQUENCE) { - AuthManager.setSelectedProfileId("meow") + AuthManager.setActiveProfileId("meow") HermesWsClient.disconnect() HermesWsClient.connect() } @@ -108,6 +108,7 @@ class ProfileSwitchCoordinatorTest { assertTrue(result is NetworkResult.Failure) verify(exactly = 0) { AuthManager.setSelectedProfileId(any()) } + verify(exactly = 0) { AuthManager.setActiveProfileId(any()) } verify(exactly = 0) { HermesWsClient.disconnect() } verify(exactly = 0) { HermesWsClient.connect() } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt index 6c9104a8..0259ea7d 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt @@ -1,9 +1,6 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.local.AuthManager -import io.mockk.every -import io.mockk.mockkObject -import io.mockk.unmockkAll import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -25,19 +22,22 @@ import org.junit.Test class WsProfileParamsTest { @Before fun setUp() { - mockkObject(AuthManager) + // NOTE: deliberately NO mockkObject(AuthManager) — see + // ProfileScopeInterceptorTest for why; the real prefs-backed state is + // used instead (setActiveProfileId is runCatching-safe pre-init). + AuthManager.resetAuthStateForTest() } @After fun tearDown() { - unmockkAll() + AuthManager.resetAuthStateForTest() } // ── Rule 1: scoped method gets profile injected ───────────────────── @Test fun `scoped method injects profile when active profile is set`() { - every { AuthManager.getSelectedProfileId() } returns "meow" + AuthManager.setActiveProfileId("meow") val original = mapOf("session_id" to "abc123") val result = WsProfileParams.decorate(WsMethods.SESSION_LIST, original) @@ -48,7 +48,7 @@ class WsProfileParamsTest { @Test fun `session_create gets profile injected`() { - every { AuthManager.getSelectedProfileId() } returns "work" + AuthManager.setActiveProfileId("work") val original = mapOf("cols" to 80) val result = WsProfileParams.decorate(WsMethods.SESSION_CREATE, original) @@ -59,7 +59,7 @@ class WsProfileParamsTest { @Test fun `session_resume gets profile injected`() { - every { AuthManager.getSelectedProfileId() } returns "meow" + AuthManager.setActiveProfileId("meow") val original = mapOf("session_id" to "s1") val result = WsProfileParams.decorate(WsMethods.SESSION_RESUME, original) @@ -70,7 +70,7 @@ class WsProfileParamsTest { @Test fun `session_delete gets profile injected`() { - every { AuthManager.getSelectedProfileId() } returns "work" + AuthManager.setActiveProfileId("work") val original = mapOf("session_id" to "s2") val result = WsProfileParams.decorate(WsMethods.SESSION_DELETE, original) @@ -80,7 +80,7 @@ class WsProfileParamsTest { @Test fun `session_status gets profile injected`() { - every { AuthManager.getSelectedProfileId() } returns "meow" + AuthManager.setActiveProfileId("meow") val original = mapOf("session_id" to "s3") val result = WsProfileParams.decorate(WsMethods.SESSION_STATUS, original) @@ -90,7 +90,7 @@ class WsProfileParamsTest { @Test fun `profile_scoped decorator method gets profile injected`() { - every { AuthManager.getSelectedProfileId() } returns "meow" + AuthManager.setActiveProfileId("meow") val original = emptyMap() val result = WsProfileParams.decorate("verification.status", original) @@ -100,7 +100,7 @@ class WsProfileParamsTest { @Test fun `empty params map gets profile injected for scoped method`() { - every { AuthManager.getSelectedProfileId() } returns "meow" + AuthManager.setActiveProfileId("meow") val result = WsProfileParams.decorate(WsMethods.SESSION_LIST, emptyMap()) @@ -112,7 +112,7 @@ class WsProfileParamsTest { @Test fun `non-scoped method returns same params reference`() { - every { AuthManager.getSelectedProfileId() } returns "meow" + AuthManager.setActiveProfileId("meow") val original = mapOf("session_id" to "s1", "text" to "hello") val result = WsProfileParams.decorate(WsMethods.PROMPT_SUBMIT, original) @@ -123,7 +123,7 @@ class WsProfileParamsTest { @Test fun `subscription method is not profile scoped`() { - every { AuthManager.getSelectedProfileId() } returns "meow" + AuthManager.setActiveProfileId("meow") val original = mapOf("plan" to "pro") val result = WsProfileParams.decorate(WsMethods.SUBSCRIPTION_STATE, original) @@ -133,7 +133,7 @@ class WsProfileParamsTest { @Test fun `clarify_respond is not profile scoped`() { - every { AuthManager.getSelectedProfileId() } returns "work" + AuthManager.setActiveProfileId("work") val original = mapOf("answer" to "yes") val result = WsProfileParams.decorate(WsMethods.CLARIFY_RESPOND, original) @@ -143,7 +143,7 @@ class WsProfileParamsTest { @Test fun `image_attach_bytes is not profile scoped`() { - every { AuthManager.getSelectedProfileId() } returns "meow" + AuthManager.setActiveProfileId("meow") val original = mapOf("data" to "base64data") val result = WsProfileParams.decorate(WsMethods.IMAGE_ATTACH_BYTES, original) @@ -153,7 +153,7 @@ class WsProfileParamsTest { @Test fun `commands_catalog is not profile scoped`() { - every { AuthManager.getSelectedProfileId() } returns "meow" + AuthManager.setActiveProfileId("meow") val original = emptyMap() val result = WsProfileParams.decorate(WsMethods.COMMANDS_CATALOG, original) @@ -163,7 +163,7 @@ class WsProfileParamsTest { @Test fun `unknown method is not profile scoped`() { - every { AuthManager.getSelectedProfileId() } returns "meow" + AuthManager.setActiveProfileId("meow") val original = mapOf("foo" to "bar") val result = WsProfileParams.decorate("setup.wizard", original) @@ -175,7 +175,7 @@ class WsProfileParamsTest { @Test fun `explicit profile in params is never overwritten`() { - every { AuthManager.getSelectedProfileId() } returns "meow" + AuthManager.setActiveProfileId("meow") val original = mapOf("session_id" to "s1", "profile" to "custom") val result = WsProfileParams.decorate(WsMethods.SESSION_LIST, original) @@ -186,7 +186,7 @@ class WsProfileParamsTest { @Test fun `explicit empty-string profile is preserved`() { - every { AuthManager.getSelectedProfileId() } returns "meow" + AuthManager.setActiveProfileId("meow") // Caller explicitly passes profile="" — the containsKey check // preserves this so the server sees the explicit value. @@ -201,7 +201,7 @@ class WsProfileParamsTest { @Test fun `null selected profile returns same params reference for scoped method`() { - every { AuthManager.getSelectedProfileId() } returns null + AuthManager.setActiveProfileId(null) val original = mapOf("session_id" to "s1") val result = WsProfileParams.decorate(WsMethods.SESSION_LIST, original) @@ -212,7 +212,7 @@ class WsProfileParamsTest { @Test fun `null selected profile returns same params for non-scoped method`() { - every { AuthManager.getSelectedProfileId() } returns null + AuthManager.setActiveProfileId(null) val original = mapOf("text" to "hello") val result = WsProfileParams.decorate(WsMethods.PROMPT_SUBMIT, original) @@ -224,7 +224,7 @@ class WsProfileParamsTest { @Test fun `all PROFILE_SCOPED_METHODS entries get profile injected`() { - every { AuthManager.getSelectedProfileId() } returns "testprofile" + AuthManager.setActiveProfileId("testprofile") for (method in WsMethods.PROFILE_SCOPED_METHODS) { val result = WsProfileParams.decorate(method, emptyMap()) @@ -294,7 +294,7 @@ class WsProfileParamsTest { @Test fun `prompt_submit is not in scoped set confirming sendMessage path is correct`() { - every { AuthManager.getSelectedProfileId() } returns "meow" + AuthManager.setActiveProfileId("meow") val params = mapOf("session_id" to "s1", "text" to "hello world") val result = WsProfileParams.decorate(WsMethods.PROMPT_SUBMIT, params) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 6d922f6a..98cc9457 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -64,10 +64,11 @@ class SlashCommandDispatchRpcTest { every { android.util.Log.e(any(), any(), any()) } returns 0 // NOTE: no mockkStatic(Dispatchers) here — a static Dispatchers mock - // bleeds JVM-wide and breaks later classes (CI 2026-08-06: this - // class's own static IO mock cross-wired COMMAND_DISPATCH captures in - // suite order). setMain alone is safe — the slash-dispatch send path - // is synchronous (no IO hop). + // bleeds JVM-wide and breaks later classes (the same poison removed + // from ProfileSwitchCoordinatorTest / ProfilesViewModelTest; CI + // 2026-08-06: this class's own static IO mock cross-wired + // COMMAND_DISPATCH captures in suite order). setMain alone is safe — + // the slash-dispatch send path is synchronous (no IO hop). mockkObject(AuthManager) mockkObject(HermesWsClient) mockkObject(ApiClient) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt index 4695c035..7c162ccb 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt @@ -414,6 +414,32 @@ class ConnectViewModelTest { assertEquals("https://127.0.0.1:9119/", state.baseUrl) } + @Test + fun testLoadSavedValues_restartWithScopedProfile_resolvesTokenViaFallback() { + val meow = + ConnectionProfile( + id = "meow", + name = "meow", + baseUrl = "https://127.0.0.1:9119/", + ) + // Restart seam (phase 1): the app was killed while meow was selected; + // only the default profile holds the connection token. The per-server + // token fallback resolves it, so the restart lands on meow WITHOUT a + // login screen — desktop-equivalent "kill + reopen → still meow". + every { AuthManager.getToken() } returns "conn-token" + every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/" + every { AuthManager.getConnectionProfiles() } returns listOf(meow) + every { AuthManager.getSelectedProfileId() } returns "meow" + + val viewModel = ConnectViewModel(mockApp) + val state = viewModel.uiState.value + + assertEquals("conn-token", state.token) + assertEquals("meow", state.profileName) + assertEquals(meow, state.selectedProfile) + assertEquals("https://127.0.0.1:9119/", state.baseUrl) + } + @Test fun testSelectProfile_withoutToken_usesEmptyString() { every { AuthManager.getProfileToken(any()) } returns null diff --git a/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt index 65ebcb81..7bd2492c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt @@ -22,7 +22,6 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject -import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -65,12 +64,11 @@ class ProfilesViewModelTest { @Before fun setUp() { + // NOTE: no mockkStatic(Dispatchers) here — a static Dispatchers mock + // bleeds into later test classes in the same JVM (see the same comment + // in ProfileSwitchCoordinatorTest). setMain alone is safe: it only + // affects Dispatchers.Main and is undone by resetMain in tearDown. Dispatchers.setMain(testDispatcher) - val testMainDispatcher = Dispatchers.Main - - mockkStatic(Dispatchers::class) - every { Dispatchers.IO } returns testDispatcher - every { Dispatchers.Main } returns testMainDispatcher mockkObject(AuthManager) storedPinnedModels = mutableListOf() @@ -113,7 +111,7 @@ class ProfilesViewModelTest { } private fun createViewModel(): ProfilesViewModel { - val vm = ProfilesViewModel() + val vm = ProfilesViewModel(ioDispatcher = testDispatcher) testDispatcher.scheduler.advanceUntilIdle() return vm }