diff --git a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt index 3c6d94dd..777a65f8 100644 --- a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt +++ b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt @@ -8,6 +8,9 @@ class HermesControlApp : Application() { override fun onCreate() { super.onCreate() AuthManager.init(this) + // Issue #478: guarantee a "Default" profile is always selected so there is no + // separate standalone/default code path anywhere in the app. + AuthManager.ensureDefaultProfile() NetworkMonitor.init(this) } } 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 15e59835..4201cfc4 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 @@ -34,8 +34,12 @@ import kotlinx.coroutines.runBlocking object AuthManager { private const val PREFS_FILE = "hermes_secure_prefs" - private const val KEY_TOKEN = "auth_token" + const val DEFAULT_PROFILE_ID = "default" + const val DEFAULT_PROFILE_NAME = "Default" + private const val KEY_SELECTED_PROFILE_ID = "selected_profile_id" private const val KEY_SESSION_COOKIE = "session_cookie" + private const val KEY_LEGACY_TOKEN = "auth_token" + private const val KEY_LEGACY_DEFAULT_MIGRATED = "legacy_default_migrated" @Volatile private var prefsDeferred: Deferred? = null @@ -114,6 +118,7 @@ object AuthManager { EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) + migrateLegacyDefaultIfNeeded(p) _tokenFlow.value = getTokenInternal(p) p } @@ -182,6 +187,75 @@ object AuthManager { serverStore.update { it.copy(connectionProfiles = profiles) } } + /** + * Guarantees the selected profile is never null (issue #478). + * + * - If a profile is already selected (default or otherwise), nothing is changed. + * - If nothing is selected but other profiles exist, the first one is selected. + * - If there are no profiles at all (fresh install / legacy standalone), a [DEFAULT_PROFILE_ID] + * profile is created from the current top-level host/port and selected. + * + * This never injects a Default profile into an existing user's profile list, and never + * clobbers a user's explicit selection. + */ + fun ensureDefaultProfile() { + val state = serverStore.getLatestState() + val hasDefault = state.connectionProfiles.any { it.id == DEFAULT_PROFILE_ID } + val needsSelection = state.selectedProfileId.isNullOrBlank() + + if (!needsSelection && (hasDefault || state.connectionProfiles.isNotEmpty())) return + + if (state.connectionProfiles.isEmpty()) { + // Fresh install / legacy standalone: create the Default profile and select it. + serverStore.update { s -> + s.copy( + connectionProfiles = + listOf( + ConnectionProfile( + id = DEFAULT_PROFILE_ID, + name = DEFAULT_PROFILE_NAME, + host = s.host, + port = s.port, + ), + ), + selectedProfileId = DEFAULT_PROFILE_ID, + ) + } + return + } + + // Profiles exist but nothing is selected: pick the first one so selection is non-null. + if (needsSelection) { + serverStore.update { s -> s.copy(selectedProfileId = s.connectionProfiles.first().id) } + } + } + + /** Ensure a profile is selected (the default one if nothing else), so callers never see null. */ + fun ensureDefaultSelected() { + ensureDefaultProfile() + if (getSelectedProfileId().isNullOrBlank()) { + setSelectedProfileId(DEFAULT_PROFILE_ID) + } + } + + /** + * One-time migration: fold the legacy standalone ([KEY_LEGACY_TOKEN]) credentials into the + * new default [ConnectionProfile]. Runs once per install, guarded by + * [KEY_LEGACY_DEFAULT_MIGRATED]. + */ + private fun migrateLegacyDefaultIfNeeded(p: SharedPreferences) { + if (p.getBoolean(KEY_LEGACY_DEFAULT_MIGRATED, false)) return + val legacyToken = p.getString(KEY_LEGACY_TOKEN, null) + ensureDefaultProfile() + p.edit().apply { + if (!legacyToken.isNullOrBlank()) { + putString("token_$DEFAULT_PROFILE_ID", legacyToken) + } + remove(KEY_LEGACY_TOKEN) + putBoolean(KEY_LEGACY_DEFAULT_MIGRATED, true) + }.apply() + } + // ── Pinned Models ──────────────────────────────────────────────────── fun getPinnedModels(): List = serverStore.getLatestState().pinnedModels @@ -253,12 +327,7 @@ object AuthManager { synchronized(this) { if (tokenInitialized) return cachedToken val selectedId = getSelectedProfileId() - val token = - if (selectedId != null) { - getProfileToken(selectedId) ?: requirePrefs().getString(KEY_TOKEN, null) - } else { - requirePrefs().getString(KEY_TOKEN, null) - } + val token = if (selectedId != null) getProfileToken(selectedId) else null cachedToken = token tokenInitialized = true return token @@ -268,20 +337,19 @@ object AuthManager { private fun getTokenInternal(p: SharedPreferences): String? { val selectedId = serverStore.getLatestState().selectedProfileId?.takeIf { it.isNotBlank() } return if (selectedId != null) { - p.getString("token_$selectedId", null) ?: p.getString(KEY_TOKEN, null) + p.getString("token_$selectedId", null) } else { - p.getString(KEY_TOKEN, null) + null } } fun setToken(token: String?) { - val selectedId = getSelectedProfileId() - if (selectedId != null) { - setProfileToken(selectedId, token) - } else { - requirePrefs().edit().putString(KEY_TOKEN, token).apply() - _tokenFlow.value = token - } + val selectedId = + getSelectedProfileId() ?: run { + ensureDefaultSelected() + DEFAULT_PROFILE_ID + } + setProfileToken(selectedId, token) synchronized(this) { cachedToken = token tokenInitialized = true @@ -293,17 +361,17 @@ object AuthManager { fun getHost(): String = serverStore.getLatestState().resolvedHost fun setHost(host: String) { - val selectedId = getSelectedProfileId() - if (selectedId != null) { - serverStore.update { state -> - val profiles = - state.connectionProfiles.map { - if (it.id == selectedId) it.copy(host = host) else it - } - state.copy(connectionProfiles = profiles) + val selectedId = + getSelectedProfileId() ?: run { + ensureDefaultSelected() + DEFAULT_PROFILE_ID } - } else { - serverStore.update { it.copy(host = host) } + serverStore.update { state -> + val profiles = + state.connectionProfiles.map { + if (it.id == selectedId) it.copy(host = host) else it + } + state.copy(connectionProfiles = profiles) } } @@ -312,17 +380,17 @@ object AuthManager { fun getPort(): Int = serverStore.getLatestState().resolvedPort fun setPort(port: Int) { - val selectedId = getSelectedProfileId() - if (selectedId != null) { - serverStore.update { state -> - val profiles = - state.connectionProfiles.map { - if (it.id == selectedId) it.copy(port = port) else it - } - state.copy(connectionProfiles = profiles) + val selectedId = + getSelectedProfileId() ?: run { + ensureDefaultSelected() + DEFAULT_PROFILE_ID } - } else { - serverStore.update { it.copy(port = port) } + serverStore.update { state -> + val profiles = + state.connectionProfiles.map { + if (it.id == selectedId) it.copy(port = port) else it + } + state.copy(connectionProfiles = profiles) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt index 72adf64e..1e45a6e6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt @@ -154,13 +154,6 @@ fun ConnectScreen( onDismissRequest = { profilesExpanded = false }, modifier = Modifier.fillMaxWidth(0.85f), ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.connect_profile_none)) }, - onClick = { - viewModel.selectProfile(null) - profilesExpanded = false - }, - ) state.profiles.forEach { profile -> DropdownMenuItem( text = { Text(profile.name) }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt index 94a00ba2..e5b8c1ac 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt @@ -54,12 +54,7 @@ class ConnectViewModel( val selectedProfile = profiles.firstOrNull { it.id == selectedId } _uiState.update { it.copy( - token = - if (selectedProfile != null) { - AuthManager.getProfileToken(selectedProfile.id) ?: "" - } else { - savedToken - }, + token = savedToken, host = selectedProfile?.host ?: savedHost, port = (selectedProfile?.port ?: savedPort).toString(), profiles = profiles, @@ -77,30 +72,17 @@ class ConnectViewModel( _uiState.update { it.copy(saveProfile = value) } } - fun selectProfile(profile: ConnectionProfile?) { - if (profile == null) { - AuthManager.setSelectedProfileId(null) - _uiState.update { - it.copy( - selectedProfile = null, - profileName = "", - token = "", - host = "127.0.0.1", - port = "9119", - ) - } - } else { - AuthManager.setSelectedProfileId(profile.id) - val token = AuthManager.getProfileToken(profile.id) ?: "" - _uiState.update { - it.copy( - selectedProfile = profile, - profileName = profile.name, - host = profile.host, - port = profile.port.toString(), - token = token, - ) - } + fun selectProfile(profile: ConnectionProfile) { + AuthManager.setSelectedProfileId(profile.id) + val token = AuthManager.getProfileToken(profile.id) ?: "" + _uiState.update { + it.copy( + selectedProfile = profile, + profileName = profile.name, + host = profile.host, + port = profile.port.toString(), + token = token, + ) } } @@ -142,7 +124,9 @@ class ConnectViewModel( } when (result) { is NetworkResult.Success -> { - // Only persist credentials to global state upon successful verification + // Persist credentials to the selected (Default) profile upon successful verification. + // setToken/setHost/setPort delegate to the currently-selected profile, which is + // always non-null after issue #478 (never the legacy standalone path). AuthManager.setToken(state.token) AuthManager.setHost(state.host) AuthManager.setPort(port) @@ -177,11 +161,10 @@ class ConnectViewModel( AuthManager.setSelectedProfileId(targetProfile.id) AuthManager.setProfileToken(targetProfile.id, state.token) } else { - // Clear selected profile if we connected standalone and didn't check "save profile" - AuthManager.setSelectedProfileId(null) - AuthManager.setToken(state.token) - AuthManager.setHost(state.host) - AuthManager.setPort(port) + // No explicit profile name — store the connection on the default profile. + AuthManager.ensureDefaultSelected() + AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) + AuthManager.setProfileToken(AuthManager.DEFAULT_PROFILE_ID, state.token) } ApiClient.rebuild() _uiState.update { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 29f9601f..f22ea339 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -359,13 +359,6 @@ private fun ConnectionSection( onDismissRequest = { profilesExpanded = false }, modifier = Modifier.fillMaxWidth(0.85f), ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.settings_profile_none)) }, - onClick = { - viewModel.selectProfile(null) - profilesExpanded = false - }, - ) state.profiles.forEach { profile -> DropdownMenuItem( text = { Text(profile.name) }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 76c16dd1..6ad54967 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -127,8 +127,13 @@ class SettingsViewModel( val updatedProfiles = AuthManager.getConnectionProfiles().filter { it.id != profileId } AuthManager.saveConnectionProfiles(updatedProfiles) AuthManager.setProfileToken(profileId, null) + // Never leave selection null (issue #478): if the deleted profile was selected, + // fall back to the default profile instead of clearing selection. if (AuthManager.getSelectedProfileId() == profileId) { - AuthManager.setSelectedProfileId(null) + if (updatedProfiles.none { it.id == AuthManager.DEFAULT_PROFILE_ID }) { + AuthManager.ensureDefaultProfile() + } + AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) } viewModelScope.launch(ioDispatcher) { loadSettings() } ApiClient.rebuild() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f49bd0d..936d1307 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -124,7 +124,6 @@ Connection Connection Profile Standalone / Default - None (Standalone / Default) Rename Profile Host Port @@ -189,7 +188,6 @@ Profile: %1$s Select Saved Profile - None (Standalone) Token is required diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index fbab956b..503e4494 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -107,6 +107,7 @@ class E2eIntegrationTest { // so only resources used by ConnectViewModel + enum labels need stubs. every { mockApp.getString(any()) } returns "" every { AuthManager.setSelectedProfileId(any()) } returns Unit + every { AuthManager.ensureDefaultSelected() } returns Unit every { AuthManager.getProfileToken(any()) } returns null every { AuthManager.setProfileToken(any(), any()) } returns Unit every { AuthManager.saveConnectionProfiles(any()) } returns Unit 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 a39baf1f..3712d1dd 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 @@ -29,6 +29,7 @@ class AuthManagerTest { private lateinit var mockPrefs: SharedPreferences private lateinit var mockEditor: SharedPreferences.Editor private lateinit var mockContext: Context + private lateinit var testContext: Context @Before fun setUp() { @@ -52,7 +53,7 @@ class AuthManagerTest { // Mock filesDir to point to temporary directory for DataStore val tempDir = java.io.File(System.getProperty("java.io.tmpdir")) - val testContext = TestContext(tempDir, mockContext) + testContext = TestContext(tempDir, mockContext) val tempFile = java.io.File(tempDir, "server_store.json") if (tempFile.exists()) { @@ -110,21 +111,21 @@ class AuthManagerTest { @Test fun testGetAndSetToken() { - every { mockPrefs.getString("auth_token", null) } returns "dummy_token_123" + AuthManager.ensureDefaultProfile() + AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) + AuthManager.setToken("dummy_token_123") assertEquals("dummy_token_123", AuthManager.getToken()) - - AuthManager.setToken("new_token") - verify { mockEditor.putString("auth_token", "new_token") } + verify { mockEditor.putString("token_${AuthManager.DEFAULT_PROFILE_ID}", "dummy_token_123") } verify { mockEditor.apply() } } @Test fun testGetAndSetToken_null() { - every { mockPrefs.getString("auth_token", null) } returns null - assertNull(AuthManager.getToken()) - + AuthManager.ensureDefaultProfile() + AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) AuthManager.setToken(null) - verify { mockEditor.putString("auth_token", null) } + assertNull(AuthManager.getToken()) + verify { mockEditor.putString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } verify { mockEditor.apply() } } @@ -155,15 +156,17 @@ class AuthManagerTest { @Test fun testTokenCaching() { - every { mockPrefs.getString("auth_token", null) } returns "first-token" + AuthManager.ensureDefaultProfile() + every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns "first-token" + AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) assertEquals("first-token", AuthManager.getToken()) // Update the mockPrefs directly, but since getToken uses the cache, it should still return the first token - every { mockPrefs.getString("auth_token", null) } returns "second-token" + every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns "second-token" assertEquals("first-token", AuthManager.getToken()) // Clear token initialized manually since this happens upon selecting profile id - AuthManager.setSelectedProfileId(null) + AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) assertEquals("second-token", AuthManager.getToken()) // Test setToken @@ -173,17 +176,21 @@ class AuthManagerTest { @Test fun testWsUrl() { + AuthManager.ensureDefaultProfile() + every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns "token123" + AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) AuthManager.setHost("hermes.local") AuthManager.setPort(1234) - every { mockPrefs.getString("auth_token", null) } returns "token123" assertEquals("ws://hermes.local:1234/api/ws?token=token123", AuthManager.wsUrl()) } @Test fun testWsUrl_nullToken() { + AuthManager.ensureDefaultProfile() + every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns null + AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) AuthManager.setHost("hermes.local") AuthManager.setPort(1234) - every { mockPrefs.getString("auth_token", null) } returns null assertEquals("ws://hermes.local:1234/api/ws?token=", AuthManager.wsUrl()) } @@ -199,19 +206,11 @@ class AuthManagerTest { } @Test - fun testGetToken_fallsBackToGlobalWhenProfileTokenMissing() { + fun testGetToken_returnsNullWhenProfileHasNoToken() { val profile1 = ConnectionProfile("prof-1", "Profile 1", "127.0.0.1", 9119) AuthManager.saveConnectionProfiles(listOf(profile1)) - every { mockPrefs.getString("token_prof-1", null) } returns null // no profile token - every { mockPrefs.getString("auth_token", null) } returns "global-token" + every { mockPrefs.getString("token_prof-1", null) } returns null AuthManager.setSelectedProfileId("prof-1") - assertEquals("global-token", AuthManager.getToken()) - } - - @Test - fun testGetToken_returnsNullWhenNoProfileAndNoGlobal() { - every { mockPrefs.getString("auth_token", null) } returns null - AuthManager.setSelectedProfileId(null) assertNull(AuthManager.getToken()) } @@ -225,18 +224,19 @@ class AuthManagerTest { verify { mockEditor.putString("token_prof-1", "new-profile-token") } verify { mockEditor.apply() } - // Should NOT write to global token + // Should NOT write to a global token key verify(exactly = 0) { mockEditor.putString("auth_token", any()) } } @Test - fun testSetToken_writesToGlobalWhenNoSelectedProfile() { - AuthManager.setSelectedProfileId(null) + fun testSetToken_writesToDefaultProfileWhenNoSelection() { + AuthManager.ensureDefaultProfile() - AuthManager.setToken("global-token") + AuthManager.setToken("default-token") - verify { mockEditor.putString("auth_token", "global-token") } + verify { mockEditor.putString("token_${AuthManager.DEFAULT_PROFILE_ID}", "default-token") } verify { mockEditor.apply() } + verify(exactly = 0) { mockEditor.putString("auth_token", any()) } } @Test @@ -259,6 +259,7 @@ class AuthManagerTest { @Test fun testGetHost_fallsBackToDefaultWhenSelectedProfileIdNotFound() { + AuthManager.ensureDefaultProfile() AuthManager.setSelectedProfileId("nonexistent") AuthManager.saveConnectionProfiles(emptyList()) AuthManager.setHost("192.168.1.1") @@ -292,7 +293,7 @@ class AuthManagerTest { @Test fun testProfileTokenRoundTrip() { - AuthManager.setSelectedProfileId(null) + AuthManager.ensureDefaultProfile() AuthManager.setProfileToken("prof-a", "token-a") verify { mockEditor.putString("token_prof-a", "token-a") } @@ -343,22 +344,20 @@ class AuthManagerTest { } @Test - fun testGetToken_fallsBackToGlobalWhenSelectedProfileLacksToken() { + fun testGetToken_profileWithNoTokenReturnsNull() { val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119) val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119) AuthManager.saveConnectionProfiles(listOf(profileA, profileB)) - // Profile A is selected but has no token → falls back to global + // Profile A is selected but has no token → null (no global fallback) every { mockPrefs.getString("token_prof-a", null) } returns null - every { mockPrefs.getString("auth_token", null) } returns "shared-global-token" AuthManager.setSelectedProfileId("prof-a") + assertNull(AuthManager.getToken()) - assertEquals("shared-global-token", AuthManager.getToken()) - - // Switch to Profile B, which also has no token → same global fallback + // Switch to Profile B, which also has no token → still null every { mockPrefs.getString("token_prof-b", null) } returns null AuthManager.setSelectedProfileId("prof-b") - assertEquals("shared-global-token", AuthManager.getToken()) + assertNull(AuthManager.getToken()) } @Test @@ -373,11 +372,10 @@ class AuthManagerTest { AuthManager.setSelectedProfileId("prof-a") assertEquals("token-a", AuthManager.getToken()) - // Profile B has no token, but global exists + // Profile B has no token → null every { mockPrefs.getString("token_prof-b", null) } returns null - every { mockPrefs.getString("auth_token", null) } returns "fallback" AuthManager.setSelectedProfileId("prof-b") - assertEquals("fallback", AuthManager.getToken()) + assertNull(AuthManager.getToken()) // Profile C has its own token too every { mockPrefs.getString("token_prof-c", null) } returns "token-c" @@ -427,4 +425,52 @@ class AuthManagerTest { AuthManager.setSelectedProfileId(" ") assertNull(AuthManager.getSelectedProfileId()) } + + @Test + fun testEnsureDefaultProfile_createsDefaultWhenMissing() { + AuthManager.saveConnectionProfiles(emptyList()) + AuthManager.setSelectedProfileId(null) + + AuthManager.ensureDefaultProfile() + + val profiles = AuthManager.getConnectionProfiles() + assertEquals(1, profiles.size) + assertEquals(AuthManager.DEFAULT_PROFILE_ID, profiles[0].id) + assertEquals(AuthManager.DEFAULT_PROFILE_NAME, profiles[0].name) + assertEquals(AuthManager.DEFAULT_PROFILE_ID, AuthManager.getSelectedProfileId()) + } + + @Test + fun testEnsureDefaultProfile_doesNotDuplicateExisting() { + val existing = ConnectionProfile("prof-1", "Work", "10.0.0.1", 9119) + AuthManager.saveConnectionProfiles(listOf(existing)) + AuthManager.setSelectedProfileId("prof-1") + + AuthManager.ensureDefaultProfile() + + val profiles = AuthManager.getConnectionProfiles() + assertEquals(1, profiles.size) + assertEquals("prof-1", AuthManager.getSelectedProfileId()) + } + + @Test + fun testMigrateLegacyToken_foldsIntoDefaultProfile() { + // Simulate a legacy install: no profiles, a global auth_token, and a selected id of null. + every { mockPrefs.getString("auth_token", null) } returns "legacy-standalone-token" + every { mockPrefs.getBoolean("legacy_default_migrated", false) } returns false + + // Re-initialize so init() runs the one-time migration + 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() + } + + // token should now be persisted under the default profile key, and the legacy key removed + verify { mockEditor.putString("token_${AuthManager.DEFAULT_PROFILE_ID}", "legacy-standalone-token") } + verify { mockEditor.remove("auth_token") } + verify { mockEditor.putBoolean("legacy_default_migrated", true) } + } } 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 fc13fe7b..14cd4d20 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 @@ -410,21 +410,6 @@ class ConnectViewModelTest { verify { AuthManager.setSelectedProfileId("test-id") } } - @Test - fun testSelectProfile_null_clearsState() { - val viewModel = ConnectViewModel(mockApp) - viewModel.selectProfile(null) - - val state = viewModel.uiState.value - assertEquals("", state.profileName) - assertEquals("127.0.0.1", state.host) - assertEquals("9119", state.port) - assertEquals("", state.token) - assertNull(state.selectedProfile) - - verify { AuthManager.setSelectedProfileId(null) } - } - @Test fun testConnect_saveProfile_savesAndSelects() = runTest { @@ -525,7 +510,7 @@ class ConnectViewModelTest { } @Test - fun testConnect_withoutSaveProfile_clearsSelectedProfile() = + fun testConnect_withoutSaveProfile_persistsToDefaultProfile() = runTest { val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("standalone-token") @@ -548,9 +533,9 @@ class ConnectViewModelTest { viewModel.connect() advanceUntilIdle() - verify { AuthManager.setSelectedProfileId(null) } verify { AuthManager.setToken("standalone-token") } verify { AuthManager.setHost("10.0.0.1") } + verify { AuthManager.setPort(9119) } verify(exactly = 0) { AuthManager.saveConnectionProfiles(any()) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index cde4731e..3e4fe8e5 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test @@ -133,18 +132,18 @@ class SettingsViewModelTest { } @Test - fun testSelectProfile_nullClearsSelection() { + fun testSelectProfile_nullFallsBackToDefault() { every { AuthManager.getConnectionProfiles() } returns testProfiles storedSelectedProfileId = "prof-1" val viewModel = createViewModel() - viewModel.selectProfile(null) + viewModel.selectProfile(AuthManager.DEFAULT_PROFILE_ID) testDispatcher.scheduler.advanceUntilIdle() - verify { AuthManager.setSelectedProfileId(null) } + verify { AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) } verify { ApiClient.rebuild() } - assertNull(viewModel.uiState.value.selectedProfileId) + assertEquals(AuthManager.DEFAULT_PROFILE_ID, viewModel.uiState.value.selectedProfileId) } @Test @@ -199,12 +198,12 @@ class SettingsViewModelTest { verify { AuthManager.saveConnectionProfiles(any()) } verify { AuthManager.setProfileToken("prof-1", null) } - verify { AuthManager.setSelectedProfileId(null) } + verify { AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) } verify { ApiClient.rebuild() } } @Test - fun testDeleteProfile_nonSelected_doesNotClearSelectedId() { + fun testDeleteProfile_nonSelected_doesNotChangeSelectedId() { every { AuthManager.getConnectionProfiles() } returns testProfiles every { AuthManager.getSelectedProfileId() } returns "prof-1" @@ -214,8 +213,8 @@ class SettingsViewModelTest { verify { AuthManager.saveConnectionProfiles(any()) } verify { AuthManager.setProfileToken("prof-2", null) } - // Selected profile (prof-1) was NOT deleted — should NOT clear selection - verify(exactly = 0) { AuthManager.setSelectedProfileId(null) } + // Selected profile (prof-1) was NOT deleted — should NOT change selection + verify(exactly = 0) { AuthManager.setSelectedProfileId(any()) } verify { ApiClient.rebuild() } }