From 254cd8e745832eca8406210f73f9beb1220cf7f3 Mon Sep 17 00:00:00 2001 From: joshua-livefront <139182194+joshua-livefront@users.noreply.github.com> Date: Mon, 20 Nov 2023 11:47:01 -0500 Subject: [PATCH] BIT-654: App should generate passwords (#258) --- .../ui/platform/base/util/EventsEffect.kt | 2 +- .../feature/generator/GeneratorScreen.kt | 30 +- .../feature/generator/GeneratorViewModel.kt | 243 ++++++--- .../util/FakeGeneratorRepository.kt | 16 +- .../feature/generator/GeneratorScreenTest.kt | 24 +- .../generator/GeneratorViewModelTest.kt | 477 ++++++++++++------ 6 files changed, 547 insertions(+), 245 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/EventsEffect.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/EventsEffect.kt index 4496ccb5a2..ccfb4d2407 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/EventsEffect.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/EventsEffect.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.onEach @Composable fun EventsEffect( viewModel: BaseViewModel<*, E, *>, - handler: (E) -> Unit, + handler: suspend (E) -> Unit, ) { LaunchedEffect(key1 = Unit) { viewModel.eventFlow diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt index 0eb104fb24..ba3648a967 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt @@ -2,7 +2,6 @@ package com.x8bit.bitwarden.ui.tools.feature.generator -import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,6 +16,9 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState @@ -29,11 +31,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -68,14 +73,26 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Pa @OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod") @Composable -fun GeneratorScreen(viewModel: GeneratorViewModel = hiltViewModel()) { - +fun GeneratorScreen( + viewModel: GeneratorViewModel = hiltViewModel(), + clipboardManager: ClipboardManager = LocalClipboardManager.current, +) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current + val resources = context.resources + val snackbarHostState = remember { SnackbarHostState() } + EventsEffect(viewModel = viewModel) { event -> when (event) { - is GeneratorEvent.ShowToast -> { - Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() + GeneratorEvent.CopyTextToClipboard -> { + clipboardManager.setText(AnnotatedString(state.generatedText)) + } + + is GeneratorEvent.ShowSnackbar -> { + snackbarHostState.showSnackbar( + message = event.message(resources).toString(), + duration = SnackbarDuration.Short, + ) } } } @@ -120,6 +137,9 @@ fun GeneratorScreen(viewModel: GeneratorViewModel = hiltViewModel()) { }, ) }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { innerPadding -> ScrollContent( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt index 8f849d323f..ddc7bb90b7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt @@ -5,8 +5,14 @@ package com.x8bit.bitwarden.ui.tools.feature.generator import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.bitwarden.core.PasswordGeneratorRequest import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository +import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult +import com.x8bit.bitwarden.data.tools.generator.repository.model.PasswordGenerationOptions 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.GeneratorState.MainType.Passcode import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Passphrase import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password @@ -15,6 +21,7 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Us import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceType.AnonAddy import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.PlusAddressedEmail import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -36,15 +43,20 @@ private const val KEY_STATE = "state" @HiltViewModel class GeneratorViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, + private val generatorRepository: GeneratorRepository, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: INITIAL_STATE, ) { //region Initialization and Overrides + private var generateTextJob: Job = Job().apply { complete() } + init { - viewModelScope.launch { - stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope) + stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope) + when (val selectedType = mutableStateFlow.value.selectedType) { + is Passcode -> loadPasscodeOptions(selectedType) + is Username -> loadUsernameOptions(selectedType) } } @@ -73,29 +85,112 @@ class GeneratorViewModel @Inject constructor( is GeneratorAction.MainType.Passcode.PasscodeType.Passphrase -> { handlePassphraseSpecificAction(action) } + + is GeneratorAction.Internal.UpdateGeneratedPasswordResult -> { + handleUpdateGeneratedPasswordResult(action) + } } } //endregion Initialization and Overrides - //region Generated Field Handlers + //region Generation Handlers - private fun handleRegenerationClick() { - mutableStateFlow.update { currentState -> - currentState.copy( - // TODO(BIT-277): Replace placeholder text with function to generate new text - generatedText = currentState.generatedText.reversed(), - ) + private fun loadPasscodeOptions(selectedType: Passcode) { + when (selectedType.selectedType) { + is Passphrase -> { + mutableStateFlow.update { it.copy(selectedType = selectedType) } + // TODO: App should generate passphrases (BIT-653) + } + + is Password -> { + val options = generatorRepository.getPasswordGenerationOptions() ?: return + updateGeneratorMainType { + Passcode( + selectedType = Password( + length = options.length, + useCapitals = options.hasUppercase, + useLowercase = options.hasLowercase, + useNumbers = options.hasNumbers, + useSpecialChars = options.allowSpecial, + minNumbers = options.minNumber, + minSpecial = options.minSpecial, + avoidAmbiguousChars = options.allowAmbiguousChar, + ), + ) + } + } } } + private fun loadUsernameOptions(selectedType: Username) { + mutableStateFlow.update { + it.copy(selectedType = selectedType) + } + // TODO: Generate different username types. Plus addressed email: BIT-655 + } + + private fun savePasswordOptionsToDisk(password: Password) { + val options = PasswordGenerationOptions( + length = password.length, + allowAmbiguousChar = password.avoidAmbiguousChars, + hasNumbers = password.useNumbers, + minNumber = password.minNumbers, + hasUppercase = password.useCapitals, + minUppercase = null, + hasLowercase = password.useLowercase, + minLowercase = null, + allowSpecial = password.useSpecialChars, + minSpecial = password.minSpecial, + ) + generatorRepository.savePasswordGenerationOptions(options) + } + + private suspend fun generatePassword(password: Password) { + val request = PasswordGeneratorRequest( + lowercase = password.useLowercase, + uppercase = password.useCapitals, + numbers = password.useNumbers, + special = password.useSpecialChars, + length = password.length.toUByte(), + avoidAmbiguous = password.avoidAmbiguousChars, + minLowercase = null, + minUppercase = null, + minNumber = null, + minSpecial = null, + ) + + val result = generatorRepository.generatePassword(request) + sendAction(GeneratorAction.Internal.UpdateGeneratedPasswordResult(result)) + } + + //endregion Generation Handlers + + //region Generated Field Handlers + + private fun handleRegenerationClick() { + // Go through the update process with the current state to trigger a + // regeneration of the generated text for the same state. + updateGeneratorMainType { mutableStateFlow.value.selectedType } + } + private fun handleCopyClick() { - viewModelScope.launch { - sendEvent( - event = GeneratorEvent.ShowToast( - message = "Copied", - ), - ) + sendEvent(GeneratorEvent.CopyTextToClipboard) + } + + private fun handleUpdateGeneratedPasswordResult( + action: GeneratorAction.Internal.UpdateGeneratedPasswordResult, + ) { + when (val result = action.result) { + is GeneratedPasswordResult.Success -> { + mutableStateFlow.update { + it.copy(generatedText = result.generatedString) + } + } + + GeneratedPasswordResult.InvalidRequest -> { + sendEvent(GeneratorEvent.ShowSnackbar(R.string.an_error_has_occurred.asText())) + } } } @@ -105,24 +200,8 @@ class GeneratorViewModel @Inject constructor( private fun handleMainTypeOptionSelect(action: GeneratorAction.MainTypeOptionSelect) { when (action.mainTypeOption) { - GeneratorState.MainTypeOption.PASSWORD -> handleSwitchToPasscode() - GeneratorState.MainTypeOption.USERNAME -> handleSwitchToUsername() - } - } - - private fun handleSwitchToPasscode() { - mutableStateFlow.update { currentState -> - currentState.copy( - selectedType = Passcode(), - ) - } - } - - private fun handleSwitchToUsername() { - mutableStateFlow.update { currentState -> - currentState.copy( - selectedType = Username(), - ) + GeneratorState.MainTypeOption.PASSWORD -> loadPasscodeOptions(Passcode()) + GeneratorState.MainTypeOption.USERNAME -> loadUsernameOptions(Username()) } } @@ -134,27 +213,12 @@ class GeneratorViewModel @Inject constructor( action: GeneratorAction.MainType.Passcode.PasscodeTypeOptionSelect, ) { when (action.passcodeTypeOption) { - PasscodeTypeOption.PASSWORD -> handleSwitchToPasswordType() - PasscodeTypeOption.PASSPHRASE -> handleSwitchToPassphraseType() - } - } - - private fun handleSwitchToPasswordType() { - mutableStateFlow.update { currentState -> - currentState.copy( - selectedType = Passcode( - selectedType = Password(), - ), + PasscodeTypeOption.PASSWORD -> loadPasscodeOptions( + selectedType = Passcode(selectedType = Password()), ) - } - } - private fun handleSwitchToPassphraseType() { - mutableStateFlow.update { currentState -> - currentState.copy( - selectedType = Passcode( - selectedType = Passphrase(), - ), + PasscodeTypeOption.PASSPHRASE -> loadPasscodeOptions( + selectedType = Passcode(selectedType = Passphrase()), ) } } @@ -356,29 +420,49 @@ class GeneratorViewModel @Inject constructor( //region Utility Functions - private inline fun updateGeneratorMainTypePassword( + private inline fun updateGeneratorMainType( + crossinline block: (GeneratorState.MainType) -> GeneratorState.MainType?, + ) { + val currentSelectedType = mutableStateFlow.value.selectedType + val updatedMainType = block(currentSelectedType) ?: return + mutableStateFlow.update { it.copy(selectedType = updatedMainType) } + + generateTextJob.cancel() + generateTextJob = viewModelScope.launch { + when (updatedMainType) { + is Passcode -> when (val selectedType = updatedMainType.selectedType) { + is Passphrase -> { + // TODO: App should generate passphrases (BIT-653) + } + + is Password -> { + savePasswordOptionsToDisk(selectedType) + generatePassword(selectedType) + } + } + + is Username -> { + // TODO: Generate different username types. Plus addressed email: BIT-655 + } + } + } + } + + private inline fun updateGeneratorMainTypePasscode( crossinline block: (Passcode) -> Passcode, ) { - mutableStateFlow.update { currentState -> - val currentSelectedType = currentState.selectedType - if (currentSelectedType !is Passcode) return@update currentState - - val updatedPasscode = block(currentSelectedType) - - // TODO(BIT-277): Replace placeholder text with function to generate new text - val newText = currentState.generatedText.reversed() - - currentState.copy(selectedType = updatedPasscode, generatedText = newText) + updateGeneratorMainType { + if (it !is Passcode) null else block(it) } } private inline fun updatePasswordType( crossinline block: (Password) -> Password, ) { - updateGeneratorMainTypePassword { currentSelectedType -> + updateGeneratorMainTypePasscode { currentSelectedType -> val currentPasswordType = currentSelectedType.selectedType if (currentPasswordType !is Password) { - return@updateGeneratorMainTypePassword currentSelectedType + return@updateGeneratorMainTypePasscode currentSelectedType } currentSelectedType.copy(selectedType = block(currentPasswordType)) } @@ -387,10 +471,10 @@ class GeneratorViewModel @Inject constructor( private inline fun updatePassphraseType( crossinline block: (Passphrase) -> Passphrase, ) { - updateGeneratorMainTypePassword { currentSelectedType -> + updateGeneratorMainTypePasscode { currentSelectedType -> val currentPasswordType = currentSelectedType.selectedType if (currentPasswordType !is Passphrase) { - return@updateGeneratorMainTypePassword currentSelectedType + return@updateGeneratorMainTypePasscode currentSelectedType } currentSelectedType.copy(selectedType = block(currentPasswordType)) } @@ -401,7 +485,7 @@ class GeneratorViewModel @Inject constructor( companion object { private const val PLACEHOLDER_GENERATED_TEXT = "Placeholder" - val INITIAL_STATE: GeneratorState = GeneratorState( + private val INITIAL_STATE: GeneratorState = GeneratorState( generatedText = PLACEHOLDER_GENERATED_TEXT, selectedType = Passcode( selectedType = Password(), @@ -950,6 +1034,18 @@ sealed class GeneratorAction { */ sealed class Username : MainType() } + + /** + * Models actions that the [GeneratorViewModel] itself might send. + */ + sealed class Internal : GeneratorAction() { + /** + * Indicates a generated text update is received. + */ + data class UpdateGeneratedPasswordResult( + val result: GeneratedPasswordResult, + ) : Internal() + } } /** @@ -961,7 +1057,14 @@ sealed class GeneratorAction { sealed class GeneratorEvent { /** - * Shows a toast with the given [message]. + * Copies text to the clipboard. */ - data class ShowToast(val message: String) : GeneratorEvent() + data object CopyTextToClipboard : GeneratorEvent() + + /** + * Displays the message in a snackbar. + */ + data class ShowSnackbar( + val message: Text, + ) : GeneratorEvent() } 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 1ba8b30b20..4e3b7c6ab6 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 @@ -11,7 +11,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.PasswordGenerat */ class FakeGeneratorRepository : GeneratorRepository { private var generatePasswordResult: GeneratedPasswordResult = GeneratedPasswordResult.Success( - generatedString = "pa11w0rd", + generatedString = "updatedText", ) private var passwordGenerationOptions: PasswordGenerationOptions? = null @@ -28,4 +28,18 @@ class FakeGeneratorRepository : GeneratorRepository { override fun savePasswordGenerationOptions(options: PasswordGenerationOptions) { passwordGenerationOptions = options } + + /** + * Sets the mock result for the generatePassword function. + */ + fun setMockGeneratePasswordResult(result: GeneratedPasswordResult) { + generatePasswordResult = result + } + + /** + * Sets the mock password generation options. + */ + fun setMockGeneratePasswordGenerationOptions(options: PasswordGenerationOptions?) { + passwordGenerationOptions = options + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt index 2b3a22d9e9..2368b354a7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt @@ -25,11 +25,12 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeRight import androidx.compose.ui.text.AnnotatedString 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 +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.emptyFlow import org.junit.Test @Suppress("LargeClass") @@ -49,11 +50,28 @@ class GeneratorScreenTest : BaseComposeTest() { ), ) - private val viewModel = mockk(relaxed = true) { - every { eventFlow } returns emptyFlow() + private val mutableEventFlow = MutableSharedFlow( + extraBufferCapacity = Int.MAX_VALUE, + ) + + private val viewModel = mockk< GeneratorViewModel >(relaxed = true) { + every { eventFlow } returns mutableEventFlow every { stateFlow } returns mutableStateFlow } + @Test + fun `Snackbar should be displayed with correct message on ShowSnackbar event`() { + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + mutableEventFlow.tryEmit(GeneratorEvent.ShowSnackbar("Test Snackbar Message".asText())) + + composeTestRule + .onNodeWithText("Test Snackbar Message") + .assertIsDisplayed() + } + @Test fun `clicking the Regenerate button should send RegenerateClick action`() { composeTestRule.setContent { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt index aefcbc0fcc..94edfa78b3 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt @@ -2,7 +2,12 @@ package com.x8bit.bitwarden.ui.tools.feature.generator import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult +import com.x8bit.bitwarden.data.tools.generator.repository.model.PasswordGenerationOptions +import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach @@ -14,38 +19,119 @@ class GeneratorViewModelTest : BaseViewModelTest() { private val initialState = createPasswordState() private val initialSavedStateHandle = createSavedStateHandleWithState(initialState) + private val initialPassphraseState = createPassphraseState() + private val passphraseSavedStateHandle = createSavedStateHandleWithState(initialPassphraseState) + + private val initialUsernameState = createUsernameState() + private val usernameSavedStateHandle = createSavedStateHandleWithState(initialUsernameState) + + private val fakeGeneratorRepository = FakeGeneratorRepository() + @Test fun `initial state should be correct`() = runTest { - val viewModel = GeneratorViewModel(initialSavedStateHandle) + val viewModel = createViewModel() viewModel.stateFlow.test { assertEquals(initialState, awaitItem()) } } + @Suppress("MaxLineLength") @Test - fun `RegenerateClick refreshes the generated text`() = runTest { - val viewModel = GeneratorViewModel(initialSavedStateHandle) - val initialText = viewModel.stateFlow.value.generatedText - val action = GeneratorAction.RegenerateClick + fun `RegenerateClick action for password state updates generatedText and saves password generation options on successful password generation`() = + runTest { + val updatedGeneratedPassword = "updatedPassword" - viewModel.actionChannel.trySend(action) + fakeGeneratorRepository.setMockGeneratePasswordResult( + GeneratedPasswordResult.Success(updatedGeneratedPassword), + ) - val reversedText = viewModel.stateFlow.value.generatedText - assertEquals(initialText.reversed(), reversedText) + val viewModel = createViewModel() + val initialState = viewModel.stateFlow.value + + val updatedPasswordOptions = PasswordGenerationOptions( + length = 14, + allowAmbiguousChar = false, + hasNumbers = true, + minNumber = 1, + hasUppercase = true, + minUppercase = null, + hasLowercase = true, + minLowercase = null, + allowSpecial = false, + minSpecial = 1, + ) + + viewModel.actionChannel.trySend(GeneratorAction.RegenerateClick) + + val expectedState = initialState.copy(generatedText = updatedGeneratedPassword) + assertEquals(expectedState, viewModel.stateFlow.value) + + assertEquals( + updatedPasswordOptions, + fakeGeneratorRepository.getPasswordGenerationOptions(), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `RegenerateClick action for password state sends ShowSnackbar event on password generation failure`() = + runTest { + fakeGeneratorRepository.setMockGeneratePasswordResult( + GeneratedPasswordResult.InvalidRequest, + ) + + val viewModel = createViewModel() + + viewModel.actionChannel.trySend(GeneratorAction.RegenerateClick) + + viewModel.eventFlow.test { + assertEquals( + GeneratorEvent.ShowSnackbar(R.string.an_error_has_occurred.asText()), + awaitItem(), + ) + } + } + + @Test + fun `RegenerateClick for passphrase state should do nothing`() = runTest { + val viewModel = GeneratorViewModel(passphraseSavedStateHandle, fakeGeneratorRepository) + + fakeGeneratorRepository.setMockGeneratePasswordResult( + GeneratedPasswordResult.Success("DifferentPassphrase"), + ) + + viewModel.actionChannel.trySend(GeneratorAction.RegenerateClick) + + assertEquals(initialPassphraseState, viewModel.stateFlow.value) } @Test - fun `CopyClick should emit ShowToast`() = runTest { - val viewModel = GeneratorViewModel(initialSavedStateHandle) + fun `RegenerateClick for username state should do nothing`() = runTest { + val viewModel = GeneratorViewModel(usernameSavedStateHandle, fakeGeneratorRepository) + + fakeGeneratorRepository.setMockGeneratePasswordResult( + GeneratedPasswordResult.Success("DifferentUsername"), + ) + + viewModel.actionChannel.trySend(GeneratorAction.RegenerateClick) + + assertEquals(initialUsernameState, viewModel.stateFlow.value) + } + + @Test + fun `CopyClick should emit CopyTextToClipboard event`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { viewModel.actionChannel.trySend(GeneratorAction.CopyClick) - assertEquals(GeneratorEvent.ShowToast("Copied"), awaitItem()) + + assertEquals(GeneratorEvent.CopyTextToClipboard, awaitItem()) } } @Test fun `MainTypeOptionSelect PASSWORD should switch to Passcode`() = runTest { - val viewModel = GeneratorViewModel(initialSavedStateHandle) + val viewModel = createViewModel() val action = GeneratorAction.MainTypeOptionSelect(GeneratorState.MainTypeOption.PASSWORD) viewModel.actionChannel.trySend(action) @@ -58,7 +144,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { @Test fun `MainTypeOptionSelect USERNAME should switch to Username`() = runTest { - val viewModel = GeneratorViewModel(initialSavedStateHandle) + val viewModel = createViewModel() val action = GeneratorAction.MainTypeOptionSelect(GeneratorState.MainTypeOption.USERNAME) viewModel.actionChannel.trySend(action) @@ -70,7 +156,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { @Test fun `PasscodeTypeOptionSelect PASSWORD should switch to PasswordType`() = runTest { - val viewModel = GeneratorViewModel(initialSavedStateHandle) + val viewModel = createViewModel() val action = GeneratorAction.MainType.Passcode.PasscodeTypeOptionSelect( passcodeTypeOption = GeneratorState.MainType.Passcode.PasscodeTypeOption.PASSWORD, ) @@ -88,7 +174,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { @Test fun `PasscodeTypeOptionSelect PASSPHRASE should switch to PassphraseType`() = runTest { - val viewModel = GeneratorViewModel(initialSavedStateHandle) + val viewModel = createViewModel() val action = GeneratorAction.MainType.Passcode.PasscodeTypeOptionSelect( passcodeTypeOption = GeneratorState.MainType.Passcode.PasscodeTypeOption.PASSPHRASE, ) @@ -111,93 +197,119 @@ class GeneratorViewModelTest : BaseViewModelTest() { @BeforeEach fun setup() { - viewModel = GeneratorViewModel(initialSavedStateHandle) + viewModel = GeneratorViewModel(initialSavedStateHandle, fakeGeneratorRepository) } + @Suppress("MaxLineLength") @Test - fun `SliderLengthChange should update password length correctly to new value`() = runTest { - viewModel.eventFlow.test { - val newLength = 16 - - viewModel.actionChannel.trySend( - GeneratorAction.MainType.Passcode.PasscodeType.Password.SliderLengthChange( - length = newLength, - ), + fun `SliderLengthChange should update password length correctly to new value and generate text`() = + runTest { + val updatedGeneratedPassword = "updatedPassword" + fakeGeneratorRepository.setMockGeneratePasswordResult( + GeneratedPasswordResult.Success(updatedGeneratedPassword), ) - val expectedState = defaultPasswordState.copy( - generatedText = "redlohecalP", - selectedType = GeneratorState.MainType.Passcode( - GeneratorState.MainType.Passcode.PasscodeType.Password( + viewModel.eventFlow.test { + val newLength = 16 + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Passcode.PasscodeType.Password.SliderLengthChange( length = newLength, ), - ), + ) + + val expectedState = defaultPasswordState.copy( + generatedText = updatedGeneratedPassword, + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + length = newLength, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } + + @Suppress("MaxLineLength") + @Test + fun `ToggleCapitalLettersChange should update useCapitals correctly and generate text`() = + runTest { + val updatedGeneratedPassword = "updatedPassword" + fakeGeneratorRepository.setMockGeneratePasswordResult( + GeneratedPasswordResult.Success(updatedGeneratedPassword), ) - assertEquals(expectedState, viewModel.stateFlow.value) + viewModel.eventFlow.test { + val useCapitals = true + + viewModel.actionChannel.trySend( + GeneratorAction + .MainType + .Passcode + .PasscodeType + .Password + .ToggleCapitalLettersChange( + useCapitals = useCapitals, + ), + ) + + val expectedState = defaultPasswordState.copy( + generatedText = updatedGeneratedPassword, + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + useCapitals = useCapitals, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } + + @Suppress("MaxLineLength") + @Test + fun `ToggleLowercaseLettersChange should update useLowercase correctly and generate text`() = + runTest { + val updatedGeneratedPassword = "updatedPassword" + fakeGeneratorRepository.setMockGeneratePasswordResult( + GeneratedPasswordResult.Success(updatedGeneratedPassword), + ) + + viewModel.eventFlow.test { + val useLowercase = true + + viewModel.actionChannel.trySend( + GeneratorAction + .MainType + .Passcode + .PasscodeType + .Password + .ToggleLowercaseLettersChange( + useLowercase = useLowercase, + ), + ) + + val expectedState = defaultPasswordState.copy( + generatedText = updatedGeneratedPassword, + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + useLowercase = useLowercase, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } } - } @Test - fun `ToggleCapitalLettersChange should update useCapitals correctly`() = runTest { - viewModel.eventFlow.test { - val useCapitals = true + fun `ToggleNumbersChange should update useNumbers correctly and generate text`() = runTest { + val updatedGeneratedPassword = "updatedPassword" + fakeGeneratorRepository.setMockGeneratePasswordResult( + GeneratedPasswordResult.Success(updatedGeneratedPassword), + ) - viewModel.actionChannel.trySend( - GeneratorAction - .MainType - .Passcode - .PasscodeType - .Password - .ToggleCapitalLettersChange( - useCapitals = useCapitals, - ), - ) - - val expectedState = defaultPasswordState.copy( - generatedText = "redlohecalP", - selectedType = GeneratorState.MainType.Passcode( - GeneratorState.MainType.Passcode.PasscodeType.Password( - useCapitals = useCapitals, - ), - ), - ) - - assertEquals(expectedState, viewModel.stateFlow.value) - } - } - - @Test - fun `ToggleLowercaseLettersChange should update useLowercase correctly`() = runTest { - viewModel.eventFlow.test { - val useLowercase = true - - viewModel.actionChannel.trySend( - GeneratorAction - .MainType - .Passcode - .PasscodeType - .Password - .ToggleLowercaseLettersChange( - useLowercase = useLowercase, - ), - ) - - val expectedState = defaultPasswordState.copy( - generatedText = "redlohecalP", - selectedType = GeneratorState.MainType.Passcode( - GeneratorState.MainType.Passcode.PasscodeType.Password( - useLowercase = useLowercase, - ), - ), - ) - - assertEquals(expectedState, viewModel.stateFlow.value) - } - } - - @Test - fun `ToggleNumbersChange should update useNumbers correctly`() = runTest { viewModel.eventFlow.test { val useNumbers = true @@ -208,7 +320,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ) val expectedState = defaultPasswordState.copy( - generatedText = "redlohecalP", + generatedText = updatedGeneratedPassword, selectedType = GeneratorState.MainType.Passcode( GeneratorState.MainType.Passcode.PasscodeType.Password( useNumbers = useNumbers, @@ -220,91 +332,118 @@ class GeneratorViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") @Test - fun `ToggleSpecialCharactersChange should update useSpecialChars correctly`() = runTest { - viewModel.eventFlow.test { - val useSpecialChars = true - - viewModel.actionChannel.trySend( - GeneratorAction - .MainType - .Passcode - .PasscodeType - .Password - .ToggleSpecialCharactersChange( - useSpecialChars = useSpecialChars, - ), + fun `ToggleSpecialCharactersChange should update useSpecialChars correctly and generate text`() = + runTest { + val updatedGeneratedPassword = "updatedPassword" + fakeGeneratorRepository.setMockGeneratePasswordResult( + GeneratedPasswordResult.Success(updatedGeneratedPassword), ) - val expectedState = defaultPasswordState.copy( - generatedText = "redlohecalP", - selectedType = GeneratorState.MainType.Passcode( - GeneratorState.MainType.Passcode.PasscodeType.Password( - useSpecialChars = useSpecialChars, - ), - ), - ) + viewModel.eventFlow.test { + val useSpecialChars = true - assertEquals(expectedState, viewModel.stateFlow.value) + viewModel.actionChannel.trySend( + GeneratorAction + .MainType + .Passcode + .PasscodeType + .Password + .ToggleSpecialCharactersChange( + useSpecialChars = useSpecialChars, + ), + ) + + val expectedState = defaultPasswordState.copy( + generatedText = updatedGeneratedPassword, + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + useSpecialChars = useSpecialChars, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } } - } + @Suppress("MaxLineLength") @Test - fun `MinNumbersCounterChange should update minNumbers correctly`() = runTest { - viewModel.eventFlow.test { - val minNumbers = 4 - - viewModel.actionChannel.trySend( - GeneratorAction.MainType.Passcode.PasscodeType.Password.MinNumbersCounterChange( - minNumbers = minNumbers, - ), + fun `MinNumbersCounterChange should update minNumbers correctly and generate text`() = + runTest { + val updatedGeneratedPassword = "updatedPassword" + fakeGeneratorRepository.setMockGeneratePasswordResult( + GeneratedPasswordResult.Success(updatedGeneratedPassword), ) - val expectedState = defaultPasswordState.copy( - generatedText = "redlohecalP", - selectedType = GeneratorState.MainType.Passcode( - GeneratorState.MainType.Passcode.PasscodeType.Password( + viewModel.eventFlow.test { + val minNumbers = 4 + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Passcode.PasscodeType.Password.MinNumbersCounterChange( minNumbers = minNumbers, ), - ), - ) + ) - assertEquals(expectedState, viewModel.stateFlow.value) - } - } - - @Test - fun `MinSpecialCharactersChange should update minSpecial correctly`() = runTest { - viewModel.eventFlow.test { - val minSpecial = 2 - - viewModel.actionChannel.trySend( - GeneratorAction - .MainType - .Passcode - .PasscodeType - .Password - .MinSpecialCharactersChange( - minSpecial = minSpecial, + val expectedState = defaultPasswordState.copy( + generatedText = updatedGeneratedPassword, + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + minNumbers = minNumbers, + ), ), - ) + ) - val expectedState = defaultPasswordState.copy( - generatedText = "redlohecalP", - selectedType = GeneratorState.MainType.Passcode( - GeneratorState.MainType.Passcode.PasscodeType.Password( - minSpecial = minSpecial, - ), - ), - ) - - assertEquals(expectedState, viewModel.stateFlow.value) + assertEquals(expectedState, viewModel.stateFlow.value) + } } - } + @Suppress("MaxLineLength") @Test - fun `ToggleAvoidAmbigousCharactersChange should update avoidAmbiguousChars correctly`() = + fun `MinSpecialCharactersChange should update minSpecial correctly and generate text`() = runTest { + val updatedGeneratedPassword = "updatedPassword" + fakeGeneratorRepository.setMockGeneratePasswordResult( + GeneratedPasswordResult.Success(updatedGeneratedPassword), + ) + + viewModel.eventFlow.test { + val minSpecial = 2 + + viewModel.actionChannel.trySend( + GeneratorAction + .MainType + .Passcode + .PasscodeType + .Password + .MinSpecialCharactersChange( + minSpecial = minSpecial, + ), + ) + + val expectedState = defaultPasswordState.copy( + generatedText = updatedGeneratedPassword, + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + minSpecial = minSpecial, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } + + @Suppress("MaxLineLength") + @Test + fun `ToggleAvoidAmbigousCharactersChange should update avoidAmbiguousChars correctly and generate text`() = + runTest { + val updatedGeneratedPassword = "updatedPassword" + fakeGeneratorRepository.setMockGeneratePasswordResult( + GeneratedPasswordResult.Success(updatedGeneratedPassword), + ) + viewModel.eventFlow.test { val avoidAmbiguousChars = true @@ -320,7 +459,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ) val expectedState = defaultPasswordState.copy( - generatedText = "redlohecalP", + generatedText = updatedGeneratedPassword, selectedType = GeneratorState.MainType.Passcode( GeneratorState.MainType.Passcode.PasscodeType.Password( avoidAmbiguousChars = avoidAmbiguousChars, @@ -344,7 +483,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { @BeforeEach fun setup() { - viewModel = GeneratorViewModel(passphraseSavedStateHandle) + viewModel = GeneratorViewModel(passphraseSavedStateHandle, fakeGeneratorRepository) } @Test @@ -364,7 +503,6 @@ class GeneratorViewModelTest : BaseViewModelTest() { ) val expectedState = defaultPassphraseState.copy( - generatedText = "redlohecalP", selectedType = GeneratorState.MainType.Passcode( GeneratorState.MainType.Passcode.PasscodeType.Passphrase( numWords = newNumWords, @@ -393,7 +531,6 @@ class GeneratorViewModelTest : BaseViewModelTest() { ) val expectedState = defaultPassphraseState.copy( - generatedText = "redlohecalP", selectedType = GeneratorState.MainType.Passcode( GeneratorState.MainType.Passcode.PasscodeType.Passphrase( wordSeparator = newWordSeparatorChar, @@ -421,7 +558,6 @@ class GeneratorViewModelTest : BaseViewModelTest() { ) val expectedState = defaultPassphraseState.copy( - generatedText = "redlohecalP", selectedType = GeneratorState.MainType.Passcode( selectedType = GeneratorState.MainType.Passcode.PasscodeType.Passphrase( includeNumber = true, @@ -449,7 +585,6 @@ class GeneratorViewModelTest : BaseViewModelTest() { ) val expectedState = defaultPassphraseState.copy( - generatedText = "redlohecalP", selectedType = GeneratorState.MainType.Passcode( GeneratorState.MainType.Passcode.PasscodeType.Passphrase( capitalize = true, @@ -465,7 +600,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { @Suppress("LongParameterList") private fun createPasswordState( - generatedText: String = "Placeholder", + generatedText: String = "defaultPassword", length: Int = 14, useCapitals: Boolean = true, useLowercase: Boolean = true, @@ -492,7 +627,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ) private fun createPassphraseState( - generatedText: String = "Placeholder", + generatedText: String = "defaultPassphrase", numWords: Int = 3, wordSeparator: Char = '-', capitalize: Boolean = false, @@ -510,10 +645,22 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), ) + private fun createUsernameState(): GeneratorState = GeneratorState( + generatedText = "defaultUsername", + selectedType = GeneratorState.MainType.Username(), + ) + private fun createSavedStateHandleWithState(state: GeneratorState) = SavedStateHandle().apply { set("state", state) } + private fun createViewModel( + state: GeneratorState? = initialState, + ): GeneratorViewModel = GeneratorViewModel( + savedStateHandle = SavedStateHandle().apply { set("state", state) }, + generatorRepository = fakeGeneratorRepository, + ) + //endregion Helper Functions }