diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt index e13749cb6c..da0153d4dd 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope import com.flipcash.app.auth.AuthManager import com.flipcash.app.auth.internal.accounts.AccountProfileFetcher import com.flipcash.app.auth.internal.accounts.AccountProfileName +import com.flipcash.app.auth.internal.accounts.CachedAccountProfile import com.flipcash.app.auth.internal.accounts.AccountRecord import com.flipcash.services.models.asHandle import com.flipcash.features.login.R @@ -129,9 +130,8 @@ class AccountSelectionViewModel @Inject constructor( .filterIsInstance() // Nothing outside drives this screen, so the pipeline seeds its own first read. .onStart { emit(Event.Load) } - .map { derive(authManager.accounts.all()) } - .onEach { derived -> - val profiles = authManager.accountProfiles.all() + .map { derive(authManager.accounts.all()) to authManager.accountProfiles.all() } + .onEach { (derived, profiles) -> dispatchEvent( Event.OnAccountsLoaded( accounts = derived.map { (record, cluster) -> @@ -141,11 +141,11 @@ class AccountSelectionViewModel @Inject constructor( ) ) } - .flatMapLatest { derived -> - derived.asFlow().flatMapMerge { merge(balance(it), profile(it)) } + .flatMapLatest { (derived, profiles) -> + derived.asFlow().flatMapMerge { merge(balance(it), profile(it, profiles)) } } .onEach { dispatchEvent(it) } - // Derivation is PBKDF2 plus a SLIP-10 chain, and the fetches are network calls; + // Derivation is PBKDF2 plus a SLIP-10 chain, and the balance fetches are network calls; // neither belongs on the thread drawing the list. .flowOn(dispatchers.IO) .launchIn(viewModelScope) @@ -225,11 +225,11 @@ class AccountSelectionViewModel @Inject constructor( private fun AccountRecord.toUiModel( cluster: AccountCluster?, - profiles: Map, + profiles: Map, ): AccountUiModel { val mnemonic = runCatching { mnemonicManager.fromEntropyBase64(entropy) }.getOrNull() val owner = cluster?.authorityPublicKey?.base58() - val profile = owner?.let { profiles[it] } + val profile = owner?.let { profiles[it]?.name } return AccountUiModel( // A record that will not derive has no owner key to be keyed on; its creation // timestamp is the only other thing distinguishing it. @@ -297,13 +297,19 @@ class AccountSelectionViewModel @Inject constructor( } /** - * One account's username and display name, fetched signed as that account, so every row is - * named and not just the signed-in one. A failure keeps whatever the cache gave the row. + * One account's username and display name, fetched by the user id cached from its last + * sign-in on this device. A row with no cached user id resolves one through Login first, as a + * last resort. A failure keeps whatever the cache gave the row. */ - private fun profile(entry: Pair>): Flow = flow { + private fun profile( + entry: Pair>, + profiles: Map, + ): Flow = flow { val (record, cluster) = entry val owner = cluster.getOrNull() ?: return@flow - profileFetcher.fetch(owner.authority.keyPair, owner.authorityPublicKey.base58()) + val ownerAddress = owner.authorityPublicKey.base58() + val userId = profiles[ownerAddress]?.userId + profileFetcher.fetch(owner.authority.keyPair, ownerAddress, userId) .onSuccess { emit(Event.OnProfileResolved(record.entropy, it)) } .onFailure { error -> trace( diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelStateTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelStateTest.kt index 2b753730f7..4a726d634e 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelStateTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelStateTest.kt @@ -4,28 +4,43 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.flipcash.app.auth.AuthManager import com.flipcash.app.auth.internal.accounts.AccountProfileCache import com.flipcash.app.auth.internal.accounts.AccountProfileFetcher +import com.flipcash.app.auth.internal.accounts.CachedAccountProfile import com.flipcash.app.auth.internal.accounts.AccountProfileName import com.flipcash.app.auth.internal.accounts.AccountRecord import com.flipcash.app.auth.internal.accounts.AccountStore import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers +import com.getcode.crypt.DerivedKey import com.getcode.crypt.MnemonicPhrase import com.getcode.opencode.controllers.TokenController import com.getcode.opencode.managers.MnemonicManager +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.base58 +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll import com.getcode.util.resources.FakeResourceHelper import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue /** @@ -88,6 +103,9 @@ class AccountSelectionViewModelStateTest { ) } + @After + fun tearDown() = unmockkAll() + @Test fun `load marks which account is signed in`() = runTest(mainCoroutineRule.dispatcher) { val store = RecordingAccountStore(listOf(record("a", 2_000L), record("b", 1_000L))) @@ -170,23 +188,106 @@ class AccountSelectionViewModelStateTest { assertEquals(listOf("b"), viewModel.stateFlow.value.accounts.map { it.entropy }) } - /** A fetched profile renames only its own row, and the username wins over the display name. */ + /** + * A single non-active, derivable row with [cached] as its cache entry. Real derivation reaches + * android.util.Base64, a stub on the JVM, so the cluster is faked; the row only needs an owner + * key to look up the cache and fetch with. + */ + private fun TestScope.loadNonActiveRow( + cached: CachedAccountProfile, + fetcher: AccountProfileFetcher = profileFetcher, + tokens: TokenController = offlineTokens(), + ): AccountSelectionViewModel { + val phrase = MnemonicPhrase(MnemonicPhrase.Kind.L12, List(12) { "abandon" }) + val ownerKey = PublicKey(List(32) { 7 }) + mockkObject(DerivedKey) + every { DerivedKey.derive(any(), phrase) } returns mockk(relaxed = true) + mockkObject(AccountCluster) + every { AccountCluster.newInstance(any(), any()) } returns + mockk(relaxed = true) { every { authorityPublicKey } returns ownerKey } + val cache: AccountProfileCache = mock { + onBlocking { all() } doReturn mapOf(ownerKey.base58() to cached) + } + whenever(authManager.accounts).thenReturn(RecordingAccountStore(listOf(record("a", 1_000L)))) + whenever(authManager.accountProfiles).thenReturn(cache) + whenever(authManager.currentEntropy).thenReturn("b") + whenever(mnemonicManager.fromEntropyBase64("a")).thenReturn(phrase) + return AccountSelectionViewModel( + authManager = authManager, + mnemonicManager = mnemonicManager, + tokenController = tokens, + profileFetcher = fetcher, + resources = resources, + dispatchers = TestDispatchers(testScheduler), + ) + } + + private fun offlineTokens(): TokenController = mock { + onBlocking { fetchTokenBalances(any()) } doReturn Result.failure(IllegalStateException("offline")) + } + + /** + * A row with no stored user id asks the fetcher to resolve one (the fetcher's Login fallback). + * The switcher itself never switches the session: [AuthManager.login] is not called. + */ + @Test + fun `a row with no stored user id is fetched without a user id`() = + runTest(mainCoroutineRule.dispatcher) { + val fetcher: AccountProfileFetcher = mock { + onBlocking { fetch(any(), any(), isNull()) } doReturn + Result.success(AccountProfileName(username = "sally_streamer", displayName = "Sally")) + } + val viewModel = loadNonActiveRow( + cached = CachedAccountProfile(userId = null, name = null), + fetcher = fetcher, + ) + advanceUntilIdle() + + assertEquals("@sally_streamer", viewModel.stateFlow.value.accounts.single().name) + verify(fetcher).fetch(any(), any(), isNull()) + verify(authManager, never()).login(any(), any(), any(), any()) + } + + /** A failed fetch, Login included, keeps whatever name the cache gave the row. */ @Test - fun `a fetched profile retitles its row`() = runTest(mainCoroutineRule.dispatcher) { - val store = RecordingAccountStore(listOf(record("a", 2_000L), record("b", 1_000L))) - val viewModel = viewModel(store, current = "b", TestDispatchers(testScheduler)) - advanceUntilIdle() + fun `a failed fetch keeps the cached name`() = + runTest(mainCoroutineRule.dispatcher) { + val fetcher: AccountProfileFetcher = mock { + onBlocking { fetch(any(), any(), anyOrNull()) } doReturn + Result.failure(IllegalStateException("offline")) + } + val viewModel = loadNonActiveRow( + cached = CachedAccountProfile( + userId = null, + name = AccountProfileName(username = "sally_streamer", displayName = "Sally"), + ), + fetcher = fetcher, + ) + advanceUntilIdle() + + val row = viewModel.stateFlow.value.accounts.single() + assertEquals("@sally_streamer", row.name) + assertFalse(row.notFound) + } - viewModel.dispatchEvent( - AccountSelectionViewModel.Event.OnProfileResolved( - entropy = "a", - name = AccountProfileName(username = "sally_streamer", displayName = "Sally"), + /** A stored user id goes straight to the fetch; the fetched names replace the cached. */ + @Test + fun `a row with a stored user id is retitled by a profile fetch`() = + runTest(mainCoroutineRule.dispatcher) { + val userId = listOf(1, 2, 3) + val fetcher: AccountProfileFetcher = mock { + onBlocking { fetch(any(), any(), eq(userId)) } doReturn + Result.success(AccountProfileName(username = null, displayName = "Sally")) + } + val viewModel = loadNonActiveRow( + cached = CachedAccountProfile(userId = userId, name = null), + fetcher = fetcher, ) - ) - advanceUntilIdle() + advanceUntilIdle() - assertEquals(listOf("@sally_streamer", ""), viewModel.stateFlow.value.accounts.map { it.name }) - } + assertEquals("Sally", viewModel.stateFlow.value.accounts.single().name) + verify(fetcher).fetch(any(), any(), eq(userId)) + } private val mnemonicName = "Apple ... Elder" diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModelTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModelTest.kt index f397c5ae1a..0702286d28 100644 --- a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModelTest.kt +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModelTest.kt @@ -68,7 +68,7 @@ class NameEntryViewModelTest { dispatchers = TestDispatchers(testScheduler) every { userManager.state } returns MutableStateFlow(UserManager.State()) every { userManager.profile } returns null - whenever(profileController.setDisplayName(any())).thenReturn(Result.success(Unit)) + whenever(profileController.setDisplayName(any())).thenReturn(Result.success(null)) val vm = createViewModel() vm.dispatchEvent(NameEntryViewModel.Event.CheckName(DisplayNameSource.Onboarding)) @@ -85,7 +85,7 @@ class NameEntryViewModelTest { dispatchers = TestDispatchers(testScheduler) every { userManager.state } returns MutableStateFlow(UserManager.State()) every { userManager.profile } returns profileNamed("Ada") - whenever(profileController.setDisplayName(any())).thenReturn(Result.success(Unit)) + whenever(profileController.setDisplayName(any())).thenReturn(Result.success(null)) val vm = createViewModel() vm.dispatchEvent(NameEntryViewModel.Event.CheckName(DisplayNameSource.MyAccount)) diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileCache.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileCache.kt index c596e3597a..aedee8381b 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileCache.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileCache.kt @@ -13,8 +13,10 @@ import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.UserProfile import com.flipcash.services.user.UserManager import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.core.NoId import com.getcode.solana.keys.base58 import com.getcode.utils.TraceType +import com.getcode.utils.hexEncodedString import com.getcode.utils.trace import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -32,14 +34,24 @@ data class AccountProfileName( val displayName: String?, ) +/** What this device remembers about an account from the last time it was signed in. */ +data class CachedAccountProfile( + /** The account's user id, which `GetProfile` is keyed by. Null until it signs in here. */ + val userId: ID?, + val name: AccountProfileName?, +) + /** * The last known username and display name of every account that has signed in on this device, * keyed by owner public key (base58). * - * Only the signed-in account has a session, so this is how the account list names the others - * before [AccountProfileFetcher] has answered, or when it cannot. It is written from two places: - * [UserManager]'s state, which every profile change of the signed-in account goes through — the - * restore at sign-in, the server refresh, and edits — and each fetch the account list makes. + * The user id cached here is what lets the account list fetch another account's profile with + * `GetProfile` directly. Only an account with no cached user id goes through the Login RPC to + * resolve one, and that id is cached too, so each account needs Login at most once on this device. + * + * Written from [UserManager]'s state, which every profile change of the signed-in account goes + * through — the restore at sign-in, the server refresh, and edits — and from each profile and user + * id the account list fetches. * * Kept out of the Block Store entry on purpose: that one has a 4KB budget sized for fixed-width * records, and a name costs nothing to lose — a missing entry falls back to the mnemonic name. The @@ -72,11 +84,22 @@ class AccountProfileCache @Inject constructor( .distinctUntilChanged() .collect { (owner, name) -> persist(owner, name) } } + scope.launch { + userManager.state + .mapNotNull { state -> + userIdFor( + owner = state.cluster?.authorityPublicKey?.base58(), + accountId = state.accountId, + ) + } + .distinctUntilChanged() + .collect { (owner, userId) -> persistUserId(owner, userId) } + } } - /** Every cached name, by owner public key. Empty when the file cannot be read. */ - suspend fun all(): Map = - runCatching { dataStore.data.first().toNames() } + /** Everything cached, by owner public key. Empty when the file cannot be read. */ + suspend fun all(): Map = + runCatching { dataStore.data.first().toEntries() } .onFailure { error -> trace(tag = TAG, message = "Could not read account profiles", error = error, type = TraceType.Error) } @@ -85,6 +108,17 @@ class AccountProfileCache @Inject constructor( /** Records a name fetched for [owner], so the next offline visit still has it. */ suspend fun put(owner: String, name: AccountProfileName) = persist(owner, name) + /** Records the user id Login resolved for [owner], so later fetches skip Login. */ + suspend fun putUserId(owner: String, userId: ID) = persistUserId(owner, userId) + + private suspend fun persistUserId(owner: String, userId: ID) { + runCatching { + dataStore.edit { prefs -> prefs[userIdKey(owner)] = userId.hexEncodedString() } + }.onFailure { error -> + trace(tag = TAG, message = "Could not cache an account's user id", error = error, type = TraceType.Error) + } + } + private suspend fun persist(owner: String, name: AccountProfileName) { runCatching { dataStore.edit { prefs -> @@ -107,24 +141,46 @@ class AccountProfileCache @Inject constructor( private const val TAG = "AccountProfileCache" private const val USERNAME = "username:" private const val DISPLAY_NAME = "displayName:" + private const val USER_ID = "userId:" private fun usernameKey(owner: String) = stringPreferencesKey(USERNAME + owner) private fun displayNameKey(owner: String) = stringPreferencesKey(DISPLAY_NAME + owner) + private fun userIdKey(owner: String) = stringPreferencesKey(USER_ID + owner) + + private val PREFIXES = listOf(USERNAME, DISPLAY_NAME, USER_ID) - private fun Preferences.toNames(): Map { + private fun Preferences.toEntries(): Map { val values = asMap().mapKeys { it.key.name } val owners = values.keys.mapNotNull { key -> - key.removePrefix(USERNAME).takeIf { key.startsWith(USERNAME) } - ?: key.removePrefix(DISPLAY_NAME).takeIf { key.startsWith(DISPLAY_NAME) } + PREFIXES.firstOrNull { key.startsWith(it) }?.let { key.removePrefix(it) } }.toSet() return owners.associateWith { owner -> - AccountProfileName( - username = values[USERNAME + owner] as? String, - displayName = values[DISPLAY_NAME + owner] as? String, + val username = values[USERNAME + owner] as? String + val displayName = values[DISPLAY_NAME + owner] as? String + CachedAccountProfile( + userId = (values[USER_ID + owner] as? String)?.let(::decodeUserId), + name = if (username == null && displayName == null) null + else AccountProfileName(username = username, displayName = displayName), ) } } + /** Null for anything that is not an even run of hex digits, so a bad entry is just absent. */ + internal fun decodeUserId(hex: String): ID? { + if (hex.isEmpty() || hex.length % 2 != 0) return null + return hex.chunked(2).map { it.toIntOrNull(16)?.toByte() ?: return null } + } + + /** + * The user id to record for this state, or null when the state does not tie one to an + * owner key. [UserManager.clear] resets both at sign-out, so a pair seen here belongs to + * the same session; [NoId], the reset value, is empty and so is skipped. + */ + internal fun userIdFor(owner: String?, accountId: ID?): Pair? { + if (owner == null || accountId.isNullOrEmpty()) return null + return owner to accountId + } + /** * The entry to write for this state, or null when there is nothing to attribute. * diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileFetcher.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileFetcher.kt index 585e6b71a3..cc2021b058 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileFetcher.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileFetcher.kt @@ -5,15 +5,17 @@ import com.flipcash.services.models.ProfileIdentifier import com.flipcash.services.repository.AccountRepository import com.flipcash.services.repository.ProfileRepository import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.model.core.ID import javax.inject.Inject /** - * Fetches any stored account's username and display name, signed as that account. + * Fetches a stored account's username and display name, signed with that account's own key the + * same way the account list signs its balance lookups. * - * `GetProfile` is keyed by user id, and the only way from an owner key to its user id is `Login`, - * which the contract describes as a lookup for recovering an account — `Register` is the call that - * creates one. Both requests are signed with the account's own key, the same self-signed pattern the - * account list uses for balances, so this works for accounts that are not signed in. + * `GetProfile` is keyed by user id. The fetch uses the user id [AccountProfileCache] kept from when + * the account was signed in. Only an account with no stored user id goes through the Login RPC, + * the one call that maps an owner key to its user id; the id it returns is cached so that account + * never needs Login again on this device. * * Every answer is written to [AccountProfileCache] so the list can still name the account offline. */ @@ -26,10 +28,10 @@ class AccountProfileFetcher @Inject constructor( * The account's current names. An account the server has no profile for succeeds with both * names null — it has none — rather than failing, so a stale cached name does not outlive it. */ - suspend fun fetch(owner: KeyPair, ownerAddress: String): Result = - accountRepository.login(owner) - .mapCatching { userId -> - profileRepository.getProfile(ProfileIdentifier.UserId(userId), owner) + suspend fun fetch(owner: KeyPair, ownerAddress: String, userId: ID?): Result = + resolveUserId(owner, ownerAddress, userId) + .mapCatching { id -> + profileRepository.getProfile(ProfileIdentifier.UserId(id), owner) .map { profile -> AccountProfileName( username = profile.username?.takeIf { it.isNotBlank() }, @@ -43,4 +45,11 @@ class AccountProfileFetcher @Inject constructor( .getOrThrow() } .onSuccess { name -> cache.put(ownerAddress, name) } + + private suspend fun resolveUserId(owner: KeyPair, ownerAddress: String, userId: ID?): Result = + if (userId != null) { + Result.success(userId) + } else { + accountRepository.login(owner).onSuccess { id -> cache.putUserId(ownerAddress, id) } + } } diff --git a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileCacheTest.kt b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileCacheTest.kt index 21e288cd62..1b1f4a10f2 100644 --- a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileCacheTest.kt +++ b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileCacheTest.kt @@ -1,6 +1,8 @@ package com.flipcash.app.auth.internal.accounts import com.flipcash.services.models.UserProfile +import com.getcode.opencode.model.core.NoId +import com.getcode.utils.hexEncodedString import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -48,4 +50,30 @@ class AccountProfileCacheTest { AccountProfileCache.entryFor(owner = "owner", accountId = signedIn, profile = profile), ) } + + @Test + fun `records the signed-in owner's user id`() { + assertEquals("owner" to signedIn, AccountProfileCache.userIdFor(owner = "owner", accountId = signedIn)) + } + + /** Sign-out resets the id to NoId, which is empty; it must not be stored as an account's id. */ + @Test + fun `skips a state with no owner or no user id`() { + assertNull(AccountProfileCache.userIdFor(owner = null, accountId = signedIn)) + assertNull(AccountProfileCache.userIdFor(owner = "owner", accountId = null)) + assertNull(AccountProfileCache.userIdFor(owner = "owner", accountId = NoId)) + } + + @Test + fun `decodes a stored user id`() { + val id = listOf(0, 15, -1, 127, -128) + assertEquals(id, AccountProfileCache.decodeUserId(id.hexEncodedString())) + } + + @Test + fun `treats a malformed user id as absent`() { + assertNull(AccountProfileCache.decodeUserId("")) + assertNull(AccountProfileCache.decodeUserId("abc")) + assertNull(AccountProfileCache.decodeUserId("zz")) + } } diff --git a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileFetcherTest.kt b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileFetcherTest.kt index ab9ef234d4..8900f70124 100644 --- a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileFetcherTest.kt +++ b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountProfileFetcherTest.kt @@ -24,24 +24,23 @@ class AccountProfileFetcherTest { private val fetcher = AccountProfileFetcher(accounts, profiles, cache) @Test - fun `fetches the profile by the user id login returns, and caches it`() = runTest { - coEvery { accounts.login(owner) } returns Result.success(userId) + fun `fetches the profile by the stored user id, and caches it`() = runTest { coEvery { profiles.getProfile(ProfileIdentifier.UserId(userId), owner) } returns Result.success(UserProfile.Empty.copy(displayName = "Sally", username = "sally")) - val name = fetcher.fetch(owner, "owner").getOrThrow() + val name = fetcher.fetch(owner, "owner", userId).getOrThrow() assertEquals(AccountProfileName(username = "sally", displayName = "Sally"), name) coVerify { cache.put("owner", name) } + coVerify(exactly = 0) { accounts.login(any()) } } /** No profile is an answer, not a failure: the account has no names, so a cached one must go. */ @Test fun `an account with no profile has no names`() = runTest { - coEvery { accounts.login(owner) } returns Result.success(userId) coEvery { profiles.getProfile(any(), owner) } returns Result.failure(GetUserProfileError.NotFound()) - val name = fetcher.fetch(owner, "owner").getOrThrow() + val name = fetcher.fetch(owner, "owner", userId).getOrThrow() assertEquals(AccountProfileName(username = null, displayName = null), name) coVerify { cache.put("owner", name) } @@ -49,19 +48,35 @@ class AccountProfileFetcherTest { @Test fun `a failed fetch leaves the cache alone`() = runTest { - coEvery { accounts.login(owner) } returns Result.success(userId) coEvery { profiles.getProfile(any(), owner) } returns Result.failure(GetUserProfileError.Other()) - assertTrue(fetcher.fetch(owner, "owner").isFailure) + assertTrue(fetcher.fetch(owner, "owner", userId).isFailure) coVerify(exactly = 0) { cache.put(any(), any()) } } + /** Login is the last resort: only an account with no stored user id goes through it. */ @Test - fun `a failed login skips the profile fetch`() = runTest { - coEvery { accounts.login(owner) } returns Result.failure(IllegalStateException("denied")) + fun `resolves a missing user id with Login, and caches the id`() = runTest { + val resolved = listOf(9, 9) + coEvery { accounts.login(owner) } returns Result.success(resolved) + coEvery { profiles.getProfile(ProfileIdentifier.UserId(resolved), owner) } returns + Result.success(UserProfile.Empty.copy(displayName = "Sally", username = null)) - assertTrue(fetcher.fetch(owner, "owner").isFailure) + val name = fetcher.fetch(owner, "owner", userId = null).getOrThrow() + + assertEquals(AccountProfileName(username = null, displayName = "Sally"), name) + coVerify(exactly = 1) { accounts.login(owner) } + coVerify { cache.putUserId("owner", resolved) } + coVerify { cache.put("owner", name) } + } + + @Test + fun `a failed Login fetches nothing and leaves the cache alone`() = runTest { + coEvery { accounts.login(owner) } returns Result.failure(IllegalStateException()) + + assertTrue(fetcher.fetch(owner, "owner", userId = null).isFailure) coVerify(exactly = 0) { profiles.getProfile(any(), any()) } coVerify(exactly = 0) { cache.put(any(), any()) } + coVerify(exactly = 0) { cache.putUserId(any(), any()) } } }