diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt index 580284124..92f299d6e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt @@ -5,10 +5,10 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha -import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange @@ -20,6 +20,7 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Sub import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.TermsClick import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import dagger.hilt.android.lifecycle.HiltViewModel @@ -130,7 +131,7 @@ class CreateAccountViewModel @Inject constructor( } is RegisterResult.Error -> { - // TODO show more robust error messages BIT-763 + // TODO parse and display server errors BIT-910 mutableStateFlow.update { it.copy( loadingDialogState = LoadingDialogState.Hidden, @@ -198,6 +199,23 @@ class CreateAccountViewModel @Inject constructor( } private fun handleSubmitClick() = when { + mutableStateFlow.value.emailInput.isBlank() -> { + val dialog = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.validation_field_required + .asText(R.string.email_address.asText()), + ) + mutableStateFlow.update { it.copy(errorDialogState = dialog) } + } + + !mutableStateFlow.value.emailInput.isValidEmail() -> { + val dialog = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.invalid_email.asText(), + ) + mutableStateFlow.update { it.copy(errorDialogState = dialog) } + } + mutableStateFlow.value.passwordInput.length < MIN_PASSWORD_LENGTH -> { val dialog = BasicDialogState.Shown( title = R.string.an_error_has_occurred.asText(), @@ -206,6 +224,22 @@ class CreateAccountViewModel @Inject constructor( mutableStateFlow.update { it.copy(errorDialogState = dialog) } } + mutableStateFlow.value.passwordInput != mutableStateFlow.value.confirmPasswordInput -> { + val dialog = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.master_password_confirmation_val_message.asText(), + ) + mutableStateFlow.update { it.copy(errorDialogState = dialog) } + } + + !mutableStateFlow.value.isAcceptPoliciesToggled -> { + val dialog = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.accept_policies_error.asText(), + ) + mutableStateFlow.update { it.copy(errorDialogState = dialog) } + } + else -> { submitRegisterAccountRequest(captchaToken = null) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt new file mode 100644 index 000000000..432b9fa9c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt @@ -0,0 +1,8 @@ +package com.x8bit.bitwarden.ui.platform.base.util + +/** + * Whether or not string is a valid email address. + * + * This just checks if the string contains the "@" symbol. + */ +fun String.isValidEmail(): Boolean = contains("@") diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt index b55a9f1a6..a87b8f13a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt @@ -3,12 +3,12 @@ package com.x8bit.bitwarden.ui.auth.feature.createaccount import android.net.Uri import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test -import app.cash.turbine.testIn import app.cash.turbine.turbineScope import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha +import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange @@ -75,6 +75,49 @@ class CreateAccountViewModelTest : BaseViewModelTest() { assertEquals(savedState, viewModel.stateFlow.value) } + @Test + fun `SubmitClick with blank email should show email required`() = runTest { + val viewModel = CreateAccountViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + ) + val input = "a" + viewModel.trySendAction(EmailInputChange(input)) + val expectedState = DEFAULT_STATE.copy( + emailInput = input, + errorDialogState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.invalid_email.asText(), + ), + ) + viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) + viewModel.stateFlow.test { + assertEquals(expectedState, awaitItem()) + } + } + + @Test + fun `SubmitClick with invalid email should show invalid email`() = runTest { + val viewModel = CreateAccountViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + ) + val input = " " + viewModel.trySendAction(EmailInputChange(input)) + val expectedState = DEFAULT_STATE.copy( + emailInput = input, + errorDialogState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.validation_field_required + .asText(R.string.email_address.asText()), + ), + ) + viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) + viewModel.stateFlow.test { + assertEquals(expectedState, awaitItem()) + } + } + @Test fun `SubmitClick with password below 12 chars should show password length dialog`() = runTest { val viewModel = CreateAccountViewModel( @@ -82,8 +125,10 @@ class CreateAccountViewModelTest : BaseViewModelTest() { authRepository = mockAuthRepository, ) val input = "abcdefghikl" + viewModel.trySendAction(EmailInputChange(EMAIL)) viewModel.trySendAction(PasswordInputChange("abcdefghikl")) val expectedState = DEFAULT_STATE.copy( + emailInput = EMAIL, passwordInput = input, errorDialogState = BasicDialogState.Shown( title = R.string.an_error_has_occurred.asText(), @@ -97,13 +142,61 @@ class CreateAccountViewModelTest : BaseViewModelTest() { } @Test - fun `SubmitClick with long enough password should show and hide loading dialog`() = runTest { + fun `SubmitClick with passwords not matching should show password match dialog`() = runTest { + val viewModel = CreateAccountViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + ) + val input = "testtesttesttest" + viewModel.trySendAction(EmailInputChange("test@test.com")) + viewModel.trySendAction(PasswordInputChange(input)) + val expectedState = DEFAULT_STATE.copy( + emailInput = "test@test.com", + passwordInput = input, + errorDialogState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.master_password_confirmation_val_message.asText(), + ), + ) + viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) + viewModel.stateFlow.test { + assertEquals(expectedState, awaitItem()) + } + } + + @Test + fun `SubmitClick without policies accepted should show accept policies error`() = runTest { + val viewModel = CreateAccountViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + ) + val password = "testtesttesttest" + viewModel.trySendAction(EmailInputChange("test@test.com")) + viewModel.trySendAction(PasswordInputChange(password)) + viewModel.trySendAction(ConfirmPasswordInputChange(password)) + val expectedState = DEFAULT_STATE.copy( + emailInput = "test@test.com", + passwordInput = password, + confirmPasswordInput = password, + errorDialogState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.accept_policies_error.asText(), + ), + ) + viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) + viewModel.stateFlow.test { + assertEquals(expectedState, awaitItem()) + } + } + + @Test + fun `SubmitClick with all inputs valid should show and hide loading dialog`() = runTest { val repo = mockk { every { captchaTokenResultFlow } returns flowOf() coEvery { register( - email = "", - masterPassword = "longenoughpassword", + email = EMAIL, + masterPassword = PASSWORD, masterPasswordHint = null, captchaToken = null, ) @@ -113,23 +206,30 @@ class CreateAccountViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = repo, ) - + viewModel.trySendAction(PasswordInputChange("longenoughpassword")) + viewModel.trySendAction(EmailInputChange(EMAIL)) + viewModel.trySendAction(PasswordInputChange(PASSWORD)) + viewModel.trySendAction(ConfirmPasswordInputChange(PASSWORD)) + viewModel.trySendAction(AcceptPoliciesToggle(true)) turbineScope { val stateFlow = viewModel.stateFlow.testIn(backgroundScope) val eventFlow = viewModel.eventFlow.testIn(backgroundScope) assertEquals( - DEFAULT_STATE, - stateFlow.awaitItem(), - ) - viewModel.trySendAction(PasswordInputChange("longenoughpassword")) - assertEquals( - DEFAULT_STATE.copy(passwordInput = "longenoughpassword"), + DEFAULT_STATE.copy( + emailInput = EMAIL, + passwordInput = PASSWORD, + confirmPasswordInput = PASSWORD, + isAcceptPoliciesToggled = true, + ), stateFlow.awaitItem(), ) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) assertEquals( DEFAULT_STATE.copy( - passwordInput = "longenoughpassword", + emailInput = EMAIL, + passwordInput = PASSWORD, + confirmPasswordInput = PASSWORD, + isAcceptPoliciesToggled = true, loadingDialogState = LoadingDialogState.Shown( text = R.string.creating_account.asText(), ), @@ -138,14 +238,17 @@ class CreateAccountViewModelTest : BaseViewModelTest() { ) assertEquals( CreateAccountEvent.NavigateToLogin( - email = "", + email = EMAIL, captchaToken = "mock_token", ), eventFlow.awaitItem(), ) assertEquals( DEFAULT_STATE.copy( - passwordInput = "longenoughpassword", + emailInput = EMAIL, + passwordInput = PASSWORD, + confirmPasswordInput = PASSWORD, + isAcceptPoliciesToggled = true, loadingDialogState = LoadingDialogState.Hidden, ), stateFlow.awaitItem(), @@ -159,8 +262,8 @@ class CreateAccountViewModelTest : BaseViewModelTest() { every { captchaTokenResultFlow } returns flowOf() coEvery { register( - email = "", - masterPassword = "longenoughpassword", + email = EMAIL, + masterPassword = PASSWORD, masterPasswordHint = null, captchaToken = null, ) @@ -170,16 +273,27 @@ class CreateAccountViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = repo, ) - viewModel.trySendAction(PasswordInputChange("longenoughpassword")) + viewModel.trySendAction(EmailInputChange(EMAIL)) + viewModel.trySendAction(PasswordInputChange(PASSWORD)) + viewModel.trySendAction(ConfirmPasswordInputChange(PASSWORD)) + viewModel.trySendAction(AcceptPoliciesToggle(true)) viewModel.stateFlow.test { assertEquals( - DEFAULT_STATE.copy(passwordInput = "longenoughpassword"), + DEFAULT_STATE.copy( + emailInput = EMAIL, + passwordInput = PASSWORD, + confirmPasswordInput = PASSWORD, + isAcceptPoliciesToggled = true, + ), awaitItem(), ) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) assertEquals( DEFAULT_STATE.copy( - passwordInput = "longenoughpassword", + emailInput = EMAIL, + passwordInput = PASSWORD, + confirmPasswordInput = PASSWORD, + isAcceptPoliciesToggled = true, loadingDialogState = LoadingDialogState.Shown( text = R.string.creating_account.asText(), ), @@ -188,7 +302,10 @@ class CreateAccountViewModelTest : BaseViewModelTest() { ) assertEquals( DEFAULT_STATE.copy( - passwordInput = "longenoughpassword", + emailInput = EMAIL, + passwordInput = PASSWORD, + confirmPasswordInput = PASSWORD, + isAcceptPoliciesToggled = true, loadingDialogState = LoadingDialogState.Hidden, errorDialogState = BasicDialogState.Shown( title = R.string.an_error_has_occurred.asText(), @@ -210,8 +327,8 @@ class CreateAccountViewModelTest : BaseViewModelTest() { every { captchaTokenResultFlow } returns flowOf() coEvery { register( - email = "", - masterPassword = "longenoughpassword", + email = EMAIL, + masterPassword = PASSWORD, masterPasswordHint = null, captchaToken = null, ) @@ -221,7 +338,10 @@ class CreateAccountViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = repo, ) - viewModel.trySendAction(PasswordInputChange("longenoughpassword")) + viewModel.trySendAction(EmailInputChange(EMAIL)) + viewModel.trySendAction(PasswordInputChange(PASSWORD)) + viewModel.trySendAction(ConfirmPasswordInputChange(PASSWORD)) + viewModel.trySendAction(AcceptPoliciesToggle(true)) viewModel.eventFlow.test { viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) assertEquals( @@ -241,8 +361,8 @@ class CreateAccountViewModelTest : BaseViewModelTest() { every { captchaTokenResultFlow } returns flowOf() coEvery { register( - email = "", - masterPassword = "longenoughpassword", + email = EMAIL, + masterPassword = PASSWORD, masterPasswordHint = null, captchaToken = null, ) @@ -252,15 +372,17 @@ class CreateAccountViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = repo, ) - viewModel.trySendAction(PasswordInputChange("longenoughpassword")) + viewModel.trySendAction(EmailInputChange(EMAIL)) + viewModel.trySendAction(PasswordInputChange(PASSWORD)) + viewModel.trySendAction(ConfirmPasswordInputChange(PASSWORD)) + viewModel.trySendAction(AcceptPoliciesToggle(true)) viewModel.eventFlow.test { viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) assertEquals( CreateAccountEvent.NavigateToLogin( - email = "", + email = EMAIL, captchaToken = "mock_captcha_token", - - ), + ), awaitItem(), ) } @@ -368,7 +490,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, ) - viewModel.trySendAction(CreateAccountAction.AcceptPoliciesToggle(true)) + viewModel.trySendAction(AcceptPoliciesToggle(true)) viewModel.stateFlow.test { assertEquals(DEFAULT_STATE.copy(isAcceptPoliciesToggled = true), awaitItem()) } @@ -387,5 +509,8 @@ class CreateAccountViewModelTest : BaseViewModelTest() { ) private const val LOGIN_RESULT_PATH = "com.x8bit.bitwarden.data.auth.repository.util.CaptchaUtilsKt" + + private const val PASSWORD = "longenoughtpassword" + private const val EMAIL = "test@test.com" } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionTest.kt new file mode 100644 index 000000000..afe34ae47 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionTest.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.ui.platform.base.util + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class StringExtensionTest { + + @Test + fun `emails without an @ character should be invalid`() { + val invalidEmails = listOf( + "", + " ", + "test.com", + ) + invalidEmails.forEach { + assertFalse(it.isValidEmail()) + } + } + + @Test + fun `emails with an @ character should be valid`() { + val validEmails = listOf( + "@", + "test@test.com", + " test@test ", + ) + validEmails.forEach { + assertTrue(it.isValidEmail()) + } + } +}