diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt index 601b2cc9a..e57ab041d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart +import java.time.Instant import javax.inject.Singleton /** @@ -67,7 +68,11 @@ class GeneratorRepositoryImpl( } .onEach { encryptedPasswordHistoryListResult -> mutablePasswordHistoryStateFlow.value = encryptedPasswordHistoryListResult.fold( - onSuccess = { LocalDataState.Loaded(it) }, + onSuccess = { + LocalDataState.Loaded( + it.sortedByDescending { history -> history.lastUsedDate }, + ) + }, onFailure = { LocalDataState.Error(it) }, ) } @@ -78,7 +83,14 @@ class GeneratorRepositoryImpl( generatorSdkSource .generatePassword(passwordGeneratorRequest) .fold( - onSuccess = { GeneratedPasswordResult.Success(it) }, + onSuccess = { generatedPassword -> + val passwordHistoryView = PasswordHistoryView( + password = generatedPassword, + lastUsedDate = Instant.now(), + ) + storePasswordHistory(passwordHistoryView) + GeneratedPasswordResult.Success(generatedPassword) + }, onFailure = { GeneratedPasswordResult.InvalidRequest }, ) @@ -88,7 +100,14 @@ class GeneratorRepositoryImpl( generatorSdkSource .generatePassphrase(passphraseGeneratorRequest) .fold( - onSuccess = { GeneratedPassphraseResult.Success(it) }, + onSuccess = { generatedPassphrase -> + val passwordHistoryView = PasswordHistoryView( + password = generatedPassphrase, + lastUsedDate = Instant.now(), + ) + storePasswordHistory(passwordHistoryView) + GeneratedPassphraseResult.Success(generatedPassphrase) + }, onFailure = { GeneratedPassphraseResult.InvalidRequest }, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreen.kt index f922b4b72..ebb5d79cb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreen.kt @@ -28,11 +28,14 @@ import androidx.compose.ui.res.stringResource import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar @@ -48,6 +51,7 @@ import kotlinx.collections.immutable.persistentListOf fun PasswordHistoryScreen( onNavigateBack: () -> Unit, viewModel: PasswordHistoryViewModel = hiltViewModel(), + clipboardManager: ClipboardManager = LocalClipboardManager.current, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current @@ -56,6 +60,11 @@ fun PasswordHistoryScreen( EventsEffect(viewModel = viewModel) { event -> when (event) { PasswordHistoryEvent.NavigateBack -> onNavigateBack.invoke() + + is PasswordHistoryEvent.CopyTextToClipboard -> { + clipboardManager.setText(event.text.toAnnotatedString()) + } + is PasswordHistoryEvent.ShowToast -> { Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() } @@ -189,7 +198,7 @@ private fun PasswordHistoryError( contentAlignment = Alignment.Center, ) { Text( - text = state.message, + text = state.message.invoke(), style = MaterialTheme.typography.bodyMedium, ) Spacer(modifier = Modifier.navigationBarsPadding()) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModel.kt index 76891803f..69d1a0a8b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModel.kt @@ -1,9 +1,22 @@ package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory import android.os.Parcelable +import androidx.lifecycle.viewModelScope +import com.bitwarden.core.PasswordHistoryView +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState +import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.PasswordHistoryState.GeneratedPassword +import com.x8bit.bitwarden.ui.tools.feature.generator.util.toFormattedPattern import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -12,16 +25,61 @@ import javax.inject.Inject */ @HiltViewModel @Suppress("TooManyFunctions") -class PasswordHistoryViewModel @Inject constructor() : - BaseViewModel<PasswordHistoryState, PasswordHistoryEvent, PasswordHistoryAction>( - initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading), - ) { +class PasswordHistoryViewModel @Inject constructor( + private val generatorRepository: GeneratorRepository, +) : BaseViewModel<PasswordHistoryState, PasswordHistoryEvent, PasswordHistoryAction>( + initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading), +) { + + init { + generatorRepository + .passwordHistoryStateFlow + .map { PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + } override fun handleAction(action: PasswordHistoryAction) { when (action) { PasswordHistoryAction.CloseClick -> handleCloseClick() is PasswordHistoryAction.PasswordCopyClick -> handleCopyClick(action.password) PasswordHistoryAction.PasswordClearClick -> handlePasswordHistoryClearClick() + is PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive -> { + handleUpdatePasswordHistoryReceive(action) + } + } + } + + private fun handleUpdatePasswordHistoryReceive( + action: PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive, + ) { + val newState = when (val state = action.state) { + is LocalDataState.Loading -> PasswordHistoryState.ViewState.Loading + + is LocalDataState.Error -> { + PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText()) + } + + is LocalDataState.Loaded -> { + val passwords = state.data.map { passwordHistoryView -> + GeneratedPassword( + password = passwordHistoryView.password, + date = passwordHistoryView.lastUsedDate.toFormattedPattern( + pattern = "MM/dd/yy h:mm a", + ), + ) + } + + if (passwords.isEmpty()) { + PasswordHistoryState.ViewState.Empty + } else { + PasswordHistoryState.ViewState.Content(passwords) + } + } + } + + mutableStateFlow.update { + it.copy(viewState = newState) } } @@ -32,19 +90,13 @@ class PasswordHistoryViewModel @Inject constructor() : } private fun handlePasswordHistoryClearClick() { - sendEvent( - event = PasswordHistoryEvent.ShowToast( - message = "Not yet implemented.", - ), - ) + viewModelScope.launch { + generatorRepository.clearPasswordHistory() + } } private fun handleCopyClick(password: GeneratedPassword) { - sendEvent( - event = PasswordHistoryEvent.ShowToast( - message = "Not yet implemented.", - ), - ) + sendEvent(PasswordHistoryEvent.CopyTextToClipboard(password.password)) } } @@ -76,7 +128,7 @@ data class PasswordHistoryState( * @property message The error message to be displayed. */ @Parcelize - data class Error(val message: String) : ViewState() + data class Error(val message: Text) : ViewState() /** * Empty state for the password history screen. @@ -122,6 +174,11 @@ sealed class PasswordHistoryEvent { * Event to navigate back to the previous screen. */ data object NavigateBack : PasswordHistoryEvent() + + /** + * Copies text to the clipboard. + */ + data class CopyTextToClipboard(val text: String) : PasswordHistoryEvent() } /** @@ -145,4 +202,17 @@ sealed class PasswordHistoryAction { * Action when the close button is clicked. */ data object CloseClick : PasswordHistoryAction() + + /** + * Models actions that the [PasswordHistoryViewModel] itself might send. + */ + sealed class Internal : PasswordHistoryAction() { + + /** + * Indicates a password history update is received. + */ + data class UpdatePasswordHistoryReceive( + val state: LocalDataState<List<PasswordHistoryView>>, + ) : Internal() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/InstantExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/InstantExtensions.kt new file mode 100644 index 000000000..da4580c55 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/InstantExtensions.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.ui.tools.feature.generator.util + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.TimeZone + +/** + * Converts the [Instant] to a formatted string based on the provided pattern and time zone. + */ +fun Instant.toFormattedPattern( + pattern: String, + zone: ZoneId = TimeZone.getDefault().toZoneId(), +): String { + val formatter = DateTimeFormatter.ofPattern(pattern).withZone(zone) + return formatter.format(this) +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt index b708ab6c0..d8fafb336 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt @@ -25,20 +25,21 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassph import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource -import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.runs +import io.mockk.unmockkStatic import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.time.Instant @@ -50,6 +51,7 @@ class GeneratorRepositoryTest { private val generatorDiskSource: GeneratorDiskSource = mockk() private val authDiskSource: AuthDiskSource = mockk { every { userStateFlow } returns mutableUserStateFlow + every { userState } returns null } private val passwordHistoryDiskSource: PasswordHistoryDiskSource = mockk() private val vaultSdkSource: VaultSdkSource = mockk() @@ -64,13 +66,19 @@ class GeneratorRepositoryTest { dispatcherManager = dispatcherManager, ) - @BeforeEach - fun setUp() { - clearMocks(generatorSdkSource) + @AfterEach + fun tearDown() { + unmockkStatic(Instant::class) } @Test - fun `generatePassword should emit Success result with the generated password`() = runTest { + fun `generatePassword should emit Success result and store the generated password`() = runTest { + val fixedInstant = Instant.parse("2021-01-01T00:00:00Z") + + mockkStatic(Instant::class) + every { Instant.now() } returns fixedInstant + + val userId = "testUserId" val request = PasswordGeneratorRequest( lowercase = true, uppercase = true, @@ -83,15 +91,30 @@ class GeneratorRepositoryTest { minNumber = null, minSpecial = null, ) - val expectedResult = "GeneratedPassword123!" - coEvery { - generatorSdkSource.generatePassword(request) - } returns Result.success(expectedResult) + val generatedPassword = "GeneratedPassword123!" + val encryptedPasswordHistory = + PasswordHistory(password = generatedPassword, lastUsedDate = Instant.now()) + + coEvery { authDiskSource.userState?.activeUserId } returns userId + + coEvery { generatorSdkSource.generatePassword(request) } returns + Result.success(generatedPassword) + + coEvery { vaultSdkSource.encryptPasswordHistory(any()) } returns + Result.success(encryptedPasswordHistory) + + coEvery { passwordHistoryDiskSource.insertPasswordHistory(any()) } just runs val result = repository.generatePassword(request) - assertEquals(expectedResult, (result as GeneratedPasswordResult.Success).generatedString) + assertEquals(generatedPassword, (result as GeneratedPasswordResult.Success).generatedString) coVerify { generatorSdkSource.generatePassword(request) } + + coVerify { + passwordHistoryDiskSource.insertPasswordHistory( + encryptedPasswordHistory.toPasswordHistoryEntity(userId), + ) + } } @Test @@ -118,23 +141,47 @@ class GeneratorRepositoryTest { } @Test - fun `generatePassphrase should emit Success result with the generated passphrase`() = runTest { - val request = PassphraseGeneratorRequest( - numWords = 5.toUByte(), - capitalize = true, - includeNumber = true, - wordSeparator = '-'.toString(), - ) - val expectedResult = "Generated-Passphrase-123!" - coEvery { - generatorSdkSource.generatePassphrase(request) - } returns Result.success(expectedResult) + fun `generatePassphrase should emit Success result and store the generated passphrase`() = + runTest { + val fixedInstant = Instant.parse("2021-01-01T00:00:00Z") + mockkStatic(Instant::class) + every { Instant.now() } returns fixedInstant - val result = repository.generatePassphrase(request) + val userId = "testUserId" + val request = PassphraseGeneratorRequest( + numWords = 5.toUByte(), + capitalize = true, + includeNumber = true, + wordSeparator = "-", + ) + val generatedPassphrase = "Generated-Passphrase-123" + val encryptedPasswordHistory = + PasswordHistory(password = generatedPassphrase, lastUsedDate = Instant.now()) - assertEquals(expectedResult, (result as GeneratedPassphraseResult.Success).generatedString) - coVerify { generatorSdkSource.generatePassphrase(request) } - } + coEvery { authDiskSource.userState?.activeUserId } returns userId + + coEvery { generatorSdkSource.generatePassphrase(request) } returns + Result.success(generatedPassphrase) + + coEvery { vaultSdkSource.encryptPasswordHistory(any()) } returns + Result.success(encryptedPasswordHistory) + + coEvery { passwordHistoryDiskSource.insertPasswordHistory(any()) } just runs + + val result = repository.generatePassphrase(request) + + assertEquals( + generatedPassphrase, + (result as GeneratedPassphraseResult.Success).generatedString, + ) + coVerify { generatorSdkSource.generatePassphrase(request) } + coVerify { vaultSdkSource.encryptPasswordHistory(any()) } + coVerify { + passwordHistoryDiskSource.insertPasswordHistory( + encryptedPasswordHistory.toPasswordHistoryEntity(userId), + ) + } + } @Suppress("MaxLineLength") @Test @@ -278,25 +325,25 @@ class GeneratorRepositoryTest { val encryptedPasswordHistoryEntities = listOf( PasswordHistoryEntity( userId = USER_STATE.activeUserId, - encryptedPassword = "encryptedPassword1", - generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(), + encryptedPassword = "encryptedPassword2", + generatedDateTimeMs = Instant.parse("2021-01-02T00:00:00Z").toEpochMilli(), ), PasswordHistoryEntity( userId = USER_STATE.activeUserId, - encryptedPassword = "encryptedPassword2", - generatedDateTimeMs = Instant.parse("2021-01-02T00:00:00Z").toEpochMilli(), + encryptedPassword = "encryptedPassword1", + generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(), ), ) val decryptedPasswordHistoryList = listOf( - PasswordHistoryView( - password = "password1", - lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"), - ), PasswordHistoryView( password = "password2", lastUsedDate = Instant.parse("2021-01-02T00:00:00Z"), ), + PasswordHistoryView( + password = "password1", + lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"), + ), ) coEvery { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt index cfb2dce0a..d61b05d20 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt @@ -74,4 +74,19 @@ class FakeGeneratorRepository : GeneratorRepository { fun setMockGeneratePassphraseResult(result: GeneratedPassphraseResult) { generatePassphraseResult = result } + + /** + * Checks if a specific password is in the history. + */ + fun isPasswordStoredInHistory(password: String): Boolean { + val passwordHistoryList = mutablePasswordHistoryStateFlow.value.data.orEmpty() + return passwordHistoryList.any { it.password == password } + } + + /** + * Emits specified state to the passwordHistoryStateFlow. + */ + fun emitPasswordHistoryState(state: LocalDataState<List<PasswordHistoryView>>) { + mutablePasswordHistoryStateFlow.value = state + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreenTest.kt index 232e68432..3eacb9fec 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreenTest.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.asText import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -49,7 +50,9 @@ class PasswordHistoryScreenTest : BaseComposeTest() { @Test fun `Error state should display error message`() { val errorMessage = "Error occurred" - updateState(PasswordHistoryState(PasswordHistoryState.ViewState.Error(errorMessage))) + updateState( + PasswordHistoryState(PasswordHistoryState.ViewState.Error(errorMessage.asText())), + ) composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed() } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModelTest.kt index 128961cd7..8f488bdfc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModelTest.kt @@ -1,10 +1,18 @@ package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory import app.cash.turbine.test +import com.bitwarden.core.PasswordHistoryView +import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState +import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository +import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.tools.feature.generator.util.toFormattedPattern import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import java.time.Instant class PasswordHistoryViewModelTest : BaseViewModelTest() { @@ -18,6 +26,77 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() { } } + @Test + fun `when repository emits Loading state the state updates correctly`() = runTest { + val fakeRepository = FakeGeneratorRepository().apply { + emitPasswordHistoryState(LocalDataState.Loading) + } + val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository) + + viewModel.stateFlow.test { + val expectedState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading) + val actualState = awaitItem() + assertEquals(expectedState, actualState) + } + } + + @Test + fun `when repository emits Error state the state updates correctly`() = runTest { + val fakeRepository = FakeGeneratorRepository().apply { + emitPasswordHistoryState(LocalDataState.Error(Exception("An error has occurred."))) + } + val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository) + + viewModel.stateFlow.test { + val expectedState = PasswordHistoryState( + PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText()), + ) + val actualState = awaitItem() + assertEquals(expectedState, actualState) + } + } + + @Test + fun `when repository emits Empty state the state updates correctly`() = runTest { + val fakeRepository = FakeGeneratorRepository().apply { + emitPasswordHistoryState(LocalDataState.Loaded(emptyList())) + } + val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository) + + viewModel.stateFlow.test { + val expectedState = PasswordHistoryState(PasswordHistoryState.ViewState.Empty) + val actualState = awaitItem() + assertEquals(expectedState, actualState) + } + } + + @Test + fun `when password history updates the state updates correctly`() = runTest { + val fakeRepository = FakeGeneratorRepository() + val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository) + + val passwordHistoryView = PasswordHistoryView("password", Instant.now()) + fakeRepository.storePasswordHistory(passwordHistoryView) + + val expectedState = PasswordHistoryState( + viewState = PasswordHistoryState.ViewState.Content( + passwords = listOf( + PasswordHistoryState.GeneratedPassword( + password = "password", + date = passwordHistoryView.lastUsedDate.toFormattedPattern( + pattern = "MM/dd/yy h:mm a", + ), + ), + ), + ), + ) + + viewModel.stateFlow.test { + val actualState = awaitItem() + assertEquals(expectedState, actualState) + } + } + @Test fun `CloseClick action should emit NavigateBack event`() = runTest { val viewModel = createViewModel() @@ -29,37 +108,44 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() { } @Test - fun `PasswordCopyClick action should emit password copied ShowToast event`() = runTest { + fun `PasswordCopyClick action should emit CopyTextToClipboard event`() = runTest { val viewModel = createViewModel() + val generatedPassword = PasswordHistoryState.GeneratedPassword( + password = "testPassword", + date = "01/01/23", + ) viewModel.eventFlow.test { viewModel.actionChannel.trySend( - PasswordHistoryAction.PasswordCopyClick( - PasswordHistoryState.GeneratedPassword(password = "Password", date = "Date"), - ), + PasswordHistoryAction.PasswordCopyClick(generatedPassword), + ) + assertEquals( + PasswordHistoryEvent.CopyTextToClipboard(generatedPassword.password), + awaitItem(), ) - assertEquals(PasswordHistoryEvent.ShowToast("Not yet implemented."), awaitItem()) } } @Test - fun `PasswordClearClick action should emit password history cleared ShowToast event`() = - runTest { - val viewModel = createViewModel() + fun `PasswordClearClick action should update to Empty ViewState`() = runTest { + val fakeRepository = FakeGeneratorRepository() + val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository) - viewModel.eventFlow.test { - viewModel.actionChannel.trySend(PasswordHistoryAction.PasswordClearClick) - assertEquals( - PasswordHistoryEvent.ShowToast("Not yet implemented."), - awaitItem(), - ) - } - } + val passwordHistoryView = PasswordHistoryView("password", Instant.now()) + fakeRepository.storePasswordHistory(passwordHistoryView) + + viewModel.actionChannel.trySend(PasswordHistoryAction.PasswordClearClick) + + assertTrue(fakeRepository.passwordHistoryStateFlow.value is LocalDataState.Loaded) + assertTrue( + (fakeRepository.passwordHistoryStateFlow.value as LocalDataState.Loaded).data.isEmpty(), + ) + } //region Helper Functions private fun createViewModel(): PasswordHistoryViewModel { - return PasswordHistoryViewModel() + return PasswordHistoryViewModel(generatorRepository = FakeGeneratorRepository()) } //endregion Helper Functions diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/InstantExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/InstantExtensionsTest.kt new file mode 100644 index 000000000..e1c4f6cab --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/InstantExtensionsTest.kt @@ -0,0 +1,20 @@ +package com.x8bit.bitwarden.ui.tools.feature.generator.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.ZoneId + +class InstantExtensionsTest { + + @Test + fun `toFormattedPattern should return correctly formatted string`() { + val instant = Instant.parse("2023-12-10T15:30:00Z") + val pattern = "MM/dd/yyyy hh:mm a" + val zone = ZoneId.of("UTC") + val expectedFormattedString = "12/10/2023 03:30 PM" + val formattedString = instant.toFormattedPattern(pattern, zone) + + assertEquals(expectedFormattedString, formattedString) + } +}