[PM-11270] hide new UI in complete registration screen behind flag pt. 2 (#3812)

This commit is contained in:
Dave Severns 2024-08-23 12:52:21 -04:00 committed by GitHub
parent b7330392cc
commit 9db09c18cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 394 additions and 103 deletions

View file

@ -46,6 +46,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
@ -135,11 +136,24 @@ fun CompleteRegistrationScreen(
.nestedScroll(scrollBehavior.nestedScrollConnection), .nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
BitwardenTopAppBar( BitwardenTopAppBar(
title = stringResource(id = R.string.create_account), title = if (state.onboardingEnabled) {
stringResource(id = R.string.create_account)
} else {
stringResource(id = R.string.set_password)
},
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = R.drawable.ic_back), navigationIcon = rememberVectorPainter(id = R.drawable.ic_back),
navigationIconContentDescription = stringResource(id = R.string.back), navigationIconContentDescription = stringResource(id = R.string.back),
onNavigationIconClick = handler.onBackClick, onNavigationIconClick = handler.onBackClick,
actions = {
if (!state.onboardingEnabled) {
BitwardenTextButton(
label = state.callToActionText(),
onClick = handler.onCallToAction,
modifier = Modifier.testTag("CreateAccountButton"),
)
}
},
) )
}, },
) { innerPadding -> ) { innerPadding ->
@ -157,10 +171,11 @@ fun CompleteRegistrationScreen(
passwordHintInput = state.passwordHintInput, passwordHintInput = state.passwordHintInput,
isCheckDataBreachesToggled = state.isCheckDataBreachesToggled, isCheckDataBreachesToggled = state.isCheckDataBreachesToggled,
handler = handler, handler = handler,
modifier = Modifier.standardHorizontalMargin(), nextButtonEnabled = state.validSubmissionReady,
nextButtonEnabled = state.hasValidMasterPassword,
callToActionText = state.callToActionText(), callToActionText = state.callToActionText(),
minimumPasswordLength = state.minimumPasswordLength, minimumPasswordLength = state.minimumPasswordLength,
showNewOnboardingUi = state.onboardingEnabled,
userEmail = state.userEmail,
) )
Spacer(modifier = Modifier.navigationBarsPadding()) Spacer(modifier = Modifier.navigationBarsPadding())
} }
@ -170,6 +185,7 @@ fun CompleteRegistrationScreen(
@Suppress("LongMethod") @Suppress("LongMethod")
@Composable @Composable
private fun CompleteRegistrationContent( private fun CompleteRegistrationContent(
userEmail: String,
passwordInput: String, passwordInput: String,
passwordStrengthState: PasswordStrengthState, passwordStrengthState: PasswordStrengthState,
confirmPasswordInput: String, confirmPasswordInput: String,
@ -179,6 +195,7 @@ private fun CompleteRegistrationContent(
minimumPasswordLength: Int, minimumPasswordLength: Int,
callToActionText: String, callToActionText: String,
handler: CompleteRegistrationHandler, handler: CompleteRegistrationHandler,
showNewOnboardingUi: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -186,17 +203,29 @@ private fun CompleteRegistrationContent(
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
CompleteRegistrationContentHeader( if (showNewOnboardingUi) {
modifier = Modifier.align(Alignment.CenterHorizontally), CompleteRegistrationContentHeader(
) modifier = Modifier
Spacer(modifier = Modifier.height(24.dp)) .align(Alignment.CenterHorizontally)
BitwardenActionCard( .standardHorizontalMargin(),
actionIcon = rememberVectorPainter(id = R.drawable.ic_tooltip), )
actionText = stringResource(id = R.string.what_makes_a_password_strong), Spacer(modifier = Modifier.height(24.dp))
callToActionText = stringResource(id = R.string.learn_more), BitwardenActionCard(
onCardClicked = handler.onMakeStrongPassword, actionIcon = rememberVectorPainter(id = R.drawable.ic_tooltip),
modifier = Modifier.fillMaxWidth(), actionText = stringResource(id = R.string.what_makes_a_password_strong),
) callToActionText = stringResource(id = R.string.learn_more),
onCardClicked = handler.onMakeStrongPassword,
modifier = Modifier
.fillMaxWidth(),
)
} else {
LegacyHeaderContent(
userEmail = userEmail,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.standardHorizontalMargin(),
)
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
var showPassword by rememberSaveable { mutableStateOf(false) } var showPassword by rememberSaveable { mutableStateOf(false) }
@ -208,7 +237,8 @@ private fun CompleteRegistrationContent(
onValueChange = handler.onPasswordInputChange, onValueChange = handler.onPasswordInputChange,
modifier = Modifier modifier = Modifier
.testTag("MasterPasswordEntry") .testTag("MasterPasswordEntry")
.fillMaxWidth(), .fillMaxWidth()
.standardHorizontalMargin(),
showPasswordTestTag = "PasswordVisibilityToggle", showPasswordTestTag = "PasswordVisibilityToggle",
imeAction = ImeAction.Next, imeAction = ImeAction.Next,
) )
@ -216,7 +246,8 @@ private fun CompleteRegistrationContent(
PasswordStrengthIndicator( PasswordStrengthIndicator(
state = passwordStrengthState, state = passwordStrengthState,
currentCharacterCount = passwordInput.length, currentCharacterCount = passwordInput.length,
minimumCharacterCount = minimumPasswordLength, minimumCharacterCount = minimumPasswordLength.takeIf { showNewOnboardingUi },
modifier = Modifier.standardHorizontalMargin(),
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
BitwardenPasswordField( BitwardenPasswordField(
@ -227,7 +258,8 @@ private fun CompleteRegistrationContent(
onValueChange = handler.onConfirmPasswordInputChange, onValueChange = handler.onConfirmPasswordInputChange,
modifier = Modifier modifier = Modifier
.testTag("ConfirmMasterPasswordEntry") .testTag("ConfirmMasterPasswordEntry")
.fillMaxWidth(), .fillMaxWidth()
.standardHorizontalMargin(),
showPasswordTestTag = "ConfirmPasswordVisibilityToggle", showPasswordTestTag = "ConfirmPasswordVisibilityToggle",
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -235,32 +267,48 @@ private fun CompleteRegistrationContent(
label = stringResource(id = R.string.master_password_hint), label = stringResource(id = R.string.master_password_hint),
value = passwordHintInput, value = passwordHintInput,
onValueChange = handler.onPasswordHintChange, onValueChange = handler.onPasswordHintChange,
hint = stringResource( hint = if (showNewOnboardingUi) {
R.string.bitwarden_cannot_recover_a_lost_or_forgotten_master_password, stringResource(
), R.string.bitwarden_cannot_recover_a_lost_or_forgotten_master_password,
)
} else {
stringResource(id = R.string.master_password_description)
},
modifier = Modifier modifier = Modifier
.testTag("MasterPasswordHintLabel") .testTag("MasterPasswordHintLabel")
.fillMaxWidth(), .fillMaxWidth()
) .standardHorizontalMargin(),
BitwardenClickableText(
label = stringResource(id = R.string.learn_about_other_ways_to_prevent_account_lockout),
onClick = handler.onLearnToPreventLockout,
style = nonMaterialTypography.labelMediumProminent,
) )
if (showNewOnboardingUi) {
BitwardenClickableText(
label = stringResource(
id = R.string.learn_about_other_ways_to_prevent_account_lockout,
),
onClick = handler.onLearnToPreventLockout,
style = nonMaterialTypography.labelMediumProminent,
modifier = Modifier.standardHorizontalMargin(),
)
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
BitwardenSwitch( BitwardenSwitch(
label = stringResource(id = R.string.check_known_data_breaches_for_this_password), label = stringResource(id = R.string.check_known_data_breaches_for_this_password),
isChecked = isCheckDataBreachesToggled, isChecked = isCheckDataBreachesToggled,
onCheckedChange = handler.onCheckDataBreachesToggle, onCheckedChange = handler.onCheckDataBreachesToggle,
modifier = Modifier.testTag("CheckExposedMasterPasswordToggle"), modifier = Modifier
) .testTag("CheckExposedMasterPasswordToggle")
Spacer(modifier = Modifier.height(24.dp)) .standardHorizontalMargin(),
BitwardenFilledButton(
label = callToActionText,
isEnabled = nextButtonEnabled,
onClick = handler.onCallToAction,
modifier = Modifier.fillMaxWidth(),
) )
if (showNewOnboardingUi) {
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
label = callToActionText,
isEnabled = nextButtonEnabled,
onClick = handler.onCallToAction,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
} }
} }
@ -286,6 +334,24 @@ private fun CompleteRegistrationContentHeader(
} }
} }
@Composable
private fun LegacyHeaderContent(
userEmail: String,
modifier: Modifier = Modifier,
) {
@Suppress("MaxLineLength")
Text(
text = stringResource(
id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account,
userEmail,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier
.fillMaxWidth(),
)
}
/** /**
* Header content ordered with the image "first" and the text "second" which can be placed in a * Header content ordered with the image "first" and the text "second" which can be placed in a
* [Column] or [Row]. * [Column] or [Row].
@ -321,7 +387,7 @@ private fun OrderedHeaderContent() {
@PreviewScreenSizes @PreviewScreenSizes
@Composable @Composable
private fun CompleteRegistrationContent_preview() { private fun CompleteRegistrationContentOldUI_preview() {
BitwardenTheme { BitwardenTheme {
CompleteRegistrationContent( CompleteRegistrationContent(
passwordInput = "tortor", passwordInput = "tortor",
@ -345,6 +411,40 @@ private fun CompleteRegistrationContent_preview() {
nextButtonEnabled = true, nextButtonEnabled = true,
modifier = Modifier.standardHorizontalMargin(), modifier = Modifier.standardHorizontalMargin(),
minimumPasswordLength = 12, minimumPasswordLength = 12,
showNewOnboardingUi = false,
userEmail = "fake@email.com",
)
}
}
@PreviewScreenSizes
@Composable
private fun CompleteRegistrationContentNewUI_preview() {
BitwardenTheme {
CompleteRegistrationContent(
passwordInput = "tortor",
passwordStrengthState = PasswordStrengthState.WEAK_3,
confirmPasswordInput = "consequat",
passwordHintInput = "dissentiunt",
isCheckDataBreachesToggled = false,
handler = CompleteRegistrationHandler(
onDismissErrorDialog = {},
onContinueWithBreachedPasswordClick = {},
onBackClick = {},
onPasswordInputChange = {},
onConfirmPasswordInputChange = {},
onPasswordHintChange = {},
onCheckDataBreachesToggle = {},
onLearnToPreventLockout = {},
onMakeStrongPassword = {},
onCallToAction = {},
),
callToActionText = "Next",
nextButtonEnabled = true,
modifier = Modifier.standardHorizontalMargin(),
minimumPasswordLength = 12,
showNewOnboardingUi = true,
userEmail = "fake@email.com",
) )
} }
} }

View file

@ -29,6 +29,7 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -64,7 +65,7 @@ class CompleteRegistrationViewModel @Inject constructor(
isCheckDataBreachesToggled = true, isCheckDataBreachesToggled = true,
dialog = null, dialog = null,
passwordStrengthState = PasswordStrengthState.NONE, passwordStrengthState = PasswordStrengthState.NONE,
onBoardingEnabled = featureFlagManager.getFeatureFlag(FlagKey.OnboardingFlow), onboardingEnabled = featureFlagManager.getFeatureFlag(FlagKey.OnboardingFlow),
minimumPasswordLength = MIN_PASSWORD_LENGTH, minimumPasswordLength = MIN_PASSWORD_LENGTH,
) )
}, },
@ -82,6 +83,14 @@ class CompleteRegistrationViewModel @Inject constructor(
stateFlow stateFlow
.onEach { savedStateHandle[KEY_STATE] = it } .onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope) .launchIn(viewModelScope)
featureFlagManager
.getFeatureFlagFlow(FlagKey.OnboardingFlow)
.map {
Internal.UpdateOnboardingFeatureState(newValue = it)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
} }
@VisibleForTesting @VisibleForTesting
@ -114,6 +123,7 @@ class CompleteRegistrationViewModel @Inject constructor(
} }
CompleteRegistrationAction.CallToActionClick -> handleCallToActionClick() CompleteRegistrationAction.CallToActionClick -> handleCallToActionClick()
is Internal.UpdateOnboardingFeatureState -> handleUpdateOnboardingFeatureState(action)
} }
} }
@ -131,6 +141,12 @@ class CompleteRegistrationViewModel @Inject constructor(
} }
} }
private fun handleUpdateOnboardingFeatureState(action: Internal.UpdateOnboardingFeatureState) {
mutableStateFlow.update {
it.copy(onboardingEnabled = action.newValue)
}
}
private fun handlePasswordStrengthResult(action: ReceivePasswordStrengthResult) { private fun handlePasswordStrengthResult(action: ReceivePasswordStrengthResult) {
when (val result = action.result) { when (val result = action.result) {
is PasswordStrengthResult.Success -> { is PasswordStrengthResult.Success -> {
@ -267,14 +283,41 @@ class CompleteRegistrationViewModel @Inject constructor(
mutableStateFlow.update { it.copy(confirmPasswordInput = action.input) } mutableStateFlow.update { it.copy(confirmPasswordInput = action.input) }
} }
private fun handleCallToActionClick() { private fun handleCallToActionClick() = when {
if (!state.userEmail.isValidEmail()) { state.userEmail.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(dialog = CompleteRegistrationDialog.Error(dialog)) }
}
!state.userEmail.isValidEmail() -> {
val dialog = BasicDialogState.Shown( val dialog = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(), title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_email.asText(), message = R.string.invalid_email.asText(),
) )
mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) } mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) }
} else { }
state.passwordInput.length < MIN_PASSWORD_LENGTH -> {
val dialog = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x.asText(MIN_PASSWORD_LENGTH),
)
mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) }
}
state.passwordInput != state.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(dialog = CompleteRegistrationDialog.Error(dialog)) }
}
else -> {
submitRegisterAccountRequest( submitRegisterAccountRequest(
shouldCheckForDataBreaches = state.isCheckDataBreachesToggled, shouldCheckForDataBreaches = state.isCheckDataBreachesToggled,
shouldIgnorePasswordStrength = false, shouldIgnorePasswordStrength = false,
@ -340,7 +383,7 @@ data class CompleteRegistrationState(
val isCheckDataBreachesToggled: Boolean, val isCheckDataBreachesToggled: Boolean,
val dialog: CompleteRegistrationDialog?, val dialog: CompleteRegistrationDialog?,
val passwordStrengthState: PasswordStrengthState, val passwordStrengthState: PasswordStrengthState,
val onBoardingEnabled: Boolean, val onboardingEnabled: Boolean,
val minimumPasswordLength: Int, val minimumPasswordLength: Int,
) : Parcelable { ) : Parcelable {
@ -348,7 +391,7 @@ data class CompleteRegistrationState(
* The text to display on the call to action button. * The text to display on the call to action button.
*/ */
val callToActionText: Text val callToActionText: Text
get() = if (onBoardingEnabled) { get() = if (onboardingEnabled) {
R.string.next.asText() R.string.next.asText()
} else { } else {
R.string.create_account.asText() R.string.create_account.asText()
@ -373,10 +416,10 @@ data class CompleteRegistrationState(
/** /**
* Whether the form is valid. * Whether the form is valid.
*/ */
val hasValidMasterPassword: Boolean val validSubmissionReady: Boolean
get() = passwordInput == confirmPasswordInput && get() = passwordInput.isNotBlank() &&
passwordInput.isNotBlank() && confirmPasswordInput.isNotBlank() &&
passwordInput.length >= MIN_PASSWORD_LENGTH passwordInput.length >= minimumPasswordLength
} }
/** /**
@ -516,5 +559,10 @@ sealed class CompleteRegistrationAction {
data class ReceivePasswordStrengthResult( data class ReceivePasswordStrengthResult(
val result: PasswordStrengthResult, val result: PasswordStrengthResult,
) : Internal() ) : Internal()
/**
* Indicate on boarding feature state has been updated.
*/
data class UpdateOnboardingFeatureState(val newValue: Boolean) : Internal()
} }
} }

View file

@ -73,6 +73,17 @@ class CompleteRegistrationScreenTest : BaseComposeTest() {
} }
} }
@Test
fun `determine if using the old ui by title text`() {
composeTestRule
.onNodeWithText("Set password")
.assertIsDisplayed()
composeTestRule
.onNode(hasText("Create account") and !hasClickAction())
.assertDoesNotExist()
}
@Test @Test
fun `call to action with valid input click should send CreateAccountClick action`() { fun `call to action with valid input click should send CreateAccountClick action`() {
mutableStateFlow.update { mutableStateFlow.update {
@ -83,30 +94,11 @@ class CompleteRegistrationScreenTest : BaseComposeTest() {
} }
composeTestRule composeTestRule
.onNode(hasText("Create account") and hasClickAction()) .onNode(hasText("Create account") and hasClickAction())
.performScrollTo()
.assertIsEnabled() .assertIsEnabled()
.performClick() .performClick()
verify { viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick) } verify { viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick) }
} }
@Test
fun `call to action not enabled if passwords don't match`() {
mutableStateFlow.update {
it.copy(
passwordInput = "4321drowssap",
confirmPasswordInput = "password1234",
)
}
composeTestRule
.onNode(hasText("Create account") and hasClickAction())
.performScrollTo()
.assertIsNotEnabled()
.performClick()
verify(exactly = 0) {
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
}
}
@Test @Test
fun `close click should send CloseClick action`() { fun `close click should send CloseClick action`() {
composeTestRule.onNodeWithContentDescription("Back").performClick() composeTestRule.onNodeWithContentDescription("Back").performClick()
@ -261,28 +253,6 @@ class CompleteRegistrationScreenTest : BaseComposeTest() {
.assertCountEquals(2) .assertCountEquals(2)
} }
@Test
fun `Click on action card should send MakePasswordStrongClick action`() {
composeTestRule
.onNodeWithText("Learn more")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(CompleteRegistrationAction.MakePasswordStrongClick) }
}
@Test
fun `Click on prevent account lockout should send LearnToPreventLockoutClick action`() {
composeTestRule
.onNodeWithText("Learn about other ways to prevent account lockout")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(CompleteRegistrationAction.LearnToPreventLockoutClick)
}
}
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `NavigateToPreventAccountLockout event should invoke navigate to prevent account lockout lambda`() { fun `NavigateToPreventAccountLockout event should invoke navigate to prevent account lockout lambda`() {
@ -297,7 +267,90 @@ class CompleteRegistrationScreenTest : BaseComposeTest() {
} }
@Test @Test
fun `Header should be displayed in portrait mode`() { fun `NavigateToLogin event should invoke navigate to login lambda`() {
mutableEventFlow.tryEmit(
CompleteRegistrationEvent.NavigateToLogin(
email = EMAIL,
captchaToken = TOKEN,
),
)
assertTrue(onNavigateToLoginCalled)
}
// New Onboarding UI tests
@Test
fun `determine if using the new ui by title text`() = testWithFeatureFlagOn {
composeTestRule
.onNode(hasText("Create account") and !hasClickAction())
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Set password")
.assertDoesNotExist()
}
@Test
fun `call to action state should update with input based on if both fields are populated`() =
testWithFeatureFlagOn {
mutableStateFlow.update {
it.copy(
passwordInput = "",
confirmPasswordInput = "password1234",
)
}
composeTestRule
.onNodeWithText("Next")
.assertIsNotEnabled()
.performScrollTo()
.performClick()
verify(exactly = 0) {
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
}
mutableStateFlow.update {
it.copy(
passwordInput = "password1234",
confirmPasswordInput = "password1234",
)
}
composeTestRule
.onNodeWithText("Next")
.assertIsEnabled()
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
}
}
@Test
fun `Click on action card should send MakePasswordStrongClick action`() =
testWithFeatureFlagOn {
composeTestRule
.onNodeWithText("Learn more")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(CompleteRegistrationAction.MakePasswordStrongClick) }
}
@Test
fun `Click on prevent account lockout should send LearnToPreventLockoutClick action`() =
testWithFeatureFlagOn {
composeTestRule
.onNodeWithText("Learn about other ways to prevent account lockout")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(CompleteRegistrationAction.LearnToPreventLockoutClick)
}
}
@Test
fun `Header should be displayed in portrait mode`() = testWithFeatureFlagOn {
composeTestRule composeTestRule
.onNodeWithText("Choose your master password") .onNodeWithText("Choose your master password")
.performScrollTo() .performScrollTo()
@ -311,7 +364,7 @@ class CompleteRegistrationScreenTest : BaseComposeTest() {
@Config(qualifiers = "land") @Config(qualifiers = "land")
@Test @Test
fun `Header should be displayed in landscape mode`() { fun `Header should be displayed in landscape mode`() = testWithFeatureFlagOn {
composeTestRule composeTestRule
.onNodeWithText("Choose your master password") .onNodeWithText("Choose your master password")
.performScrollTo() .performScrollTo()
@ -323,16 +376,22 @@ class CompleteRegistrationScreenTest : BaseComposeTest() {
.assertIsDisplayed() .assertIsDisplayed()
} }
@Test private fun testWithFeatureFlagOn(test: () -> Unit) {
fun `NavigateToLogin event should invoke navigate to login lambda`() { turnFeatureFlagOn()
mutableEventFlow.tryEmit( test()
CompleteRegistrationEvent.NavigateToLogin( turnFeatureFlagOff()
email = EMAIL, }
captchaToken = TOKEN,
),
)
assertTrue(onNavigateToLoginCalled) private fun turnFeatureFlagOn() {
mutableStateFlow.update {
it.copy(onboardingEnabled = true)
}
}
private fun turnFeatureFlagOff() {
mutableStateFlow.update {
it.copy(onboardingEnabled = false)
}
} }
companion object { companion object {
@ -349,7 +408,7 @@ class CompleteRegistrationScreenTest : BaseComposeTest() {
isCheckDataBreachesToggled = true, isCheckDataBreachesToggled = true,
dialog = null, dialog = null,
passwordStrengthState = PasswordStrengthState.NONE, passwordStrengthState = PasswordStrengthState.NONE,
onBoardingEnabled = false, onboardingEnabled = false,
minimumPasswordLength = 12, minimumPasswordLength = 12,
) )
} }

View file

@ -34,6 +34,7 @@ import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.unmockkStatic import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
@ -55,9 +56,10 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
private val specialCircumstanceManager: SpecialCircumstanceManager = private val specialCircumstanceManager: SpecialCircumstanceManager =
SpecialCircumstanceManagerImpl() SpecialCircumstanceManagerImpl()
private val mutableFeatureFlagFlow = MutableStateFlow(false)
private val featureFlagManager = mockk<FeatureFlagManager>(relaxed = true) { private val featureFlagManager = mockk<FeatureFlagManager>(relaxed = true) {
every { getFeatureFlag(FlagKey.OnboardingFlow) } returns false every { getFeatureFlag(FlagKey.OnboardingFlow) } returns false
every { getFeatureFlagFlow(FlagKey.OnboardingFlow) } returns mutableFeatureFlagFlow
} }
@BeforeEach @BeforeEach
@ -108,7 +110,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
val viewModel = createCompleteRegistrationViewModel() val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange(input)) viewModel.trySendAction(PasswordInputChange(input))
assertFalse(viewModel.stateFlow.value.hasValidMasterPassword) assertFalse(viewModel.stateFlow.value.validSubmissionReady)
} }
@Test @Test
@ -120,7 +122,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
val viewModel = createCompleteRegistrationViewModel() val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange(PASSWORD)) viewModel.trySendAction(PasswordInputChange(PASSWORD))
assertFalse(viewModel.stateFlow.value.hasValidMasterPassword) assertFalse(viewModel.stateFlow.value.validSubmissionReady)
} }
@Test @Test
@ -507,6 +509,88 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
} }
} }
@Test
fun `feature flag state update is captured in ViewModel state`() {
mutableFeatureFlagFlow.value = true
val viewModel = createCompleteRegistrationViewModel()
assertTrue(viewModel.stateFlow.value.onboardingEnabled)
}
@Test
fun `CreateAccountClick with password below 12 chars should show password length dialog`() =
runTest {
val input = "abcdefghikl"
coEvery {
mockAuthRepository.getPasswordStrength(EMAIL, input)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange(input))
val expectedState = DEFAULT_STATE.copy(
passwordInput = input,
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x.asText(12),
),
),
)
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `CreateAccountClick with passwords not matching should show password match dialog`() =
runTest {
coEvery {
mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange(PASSWORD))
val expectedState = DEFAULT_STATE.copy(
userEmail = EMAIL,
passwordInput = PASSWORD,
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
),
),
)
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `CreateAccountClick with no email not should show dialog`() =
runTest {
coEvery {
mockAuthRepository.getPasswordStrength("", PASSWORD)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel(
DEFAULT_STATE.copy(userEmail = ""),
)
viewModel.trySendAction(PasswordInputChange(PASSWORD))
val expectedState = DEFAULT_STATE.copy(
userEmail = "",
passwordInput = PASSWORD,
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required
.asText(R.string.email_address.asText()),
),
),
)
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
private fun createCompleteRegistrationViewModel( private fun createCompleteRegistrationViewModel(
completeRegistrationState: CompleteRegistrationState? = DEFAULT_STATE, completeRegistrationState: CompleteRegistrationState? = DEFAULT_STATE,
authRepository: AuthRepository = mockAuthRepository, authRepository: AuthRepository = mockAuthRepository,
@ -538,7 +622,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
isCheckDataBreachesToggled = true, isCheckDataBreachesToggled = true,
dialog = null, dialog = null,
passwordStrengthState = PasswordStrengthState.NONE, passwordStrengthState = PasswordStrengthState.NONE,
onBoardingEnabled = false, onboardingEnabled = false,
minimumPasswordLength = 12, minimumPasswordLength = 12,
) )
private val VALID_INPUT_STATE = CompleteRegistrationState( private val VALID_INPUT_STATE = CompleteRegistrationState(
@ -551,7 +635,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
isCheckDataBreachesToggled = false, isCheckDataBreachesToggled = false,
dialog = null, dialog = null,
passwordStrengthState = PasswordStrengthState.GOOD, passwordStrengthState = PasswordStrengthState.GOOD,
onBoardingEnabled = false, onboardingEnabled = false,
minimumPasswordLength = 12, minimumPasswordLength = 12,
) )
} }