BIT-814, BIT-815: Add UI for Enterprise Single Sign On screen (#437)

This commit is contained in:
Caleb Derosier 2023-12-27 16:35:48 -06:00 committed by Álison Fernandes
parent a2e3984a5e
commit 800e0e018c
12 changed files with 663 additions and 9 deletions

View file

@ -8,6 +8,8 @@ import androidx.navigation.navOptions
import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.auth.feature.createaccount.createAccountDestination
import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount
import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.enterpriseSignOnDestination
import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.navigateToEnterpriseSignOn
import com.x8bit.bitwarden.ui.auth.feature.environment.environmentDestination
import com.x8bit.bitwarden.ui.auth.feature.environment.navigateToEnvironment
import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE
@ -37,6 +39,9 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
)
},
)
enterpriseSignOnDestination(
onNavigateBack = { navController.popBackStack() },
)
landingDestination(
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
onNavigateToLogin = { emailAddress ->
@ -51,6 +56,7 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
)
loginDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToEnterpriseSignOn = { navController.navigateToEnterpriseSignOn() },
)
environmentDestination(
onNavigateBack = { navController.popBackStack() },

View file

@ -0,0 +1,35 @@
package com.x8bit.bitwarden.ui.auth.feature.enterprisesignon
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.x8bit.bitwarden.ui.platform.theme.TransitionProviders
private const val ENTERPRISE_SIGN_ON_ROUTE = "enterprise_sign_on"
/**
* Navigate to the enterprise single sign on screen.
*/
fun NavController.navigateToEnterpriseSignOn(navOptions: NavOptions? = null) {
this.navigate(ENTERPRISE_SIGN_ON_ROUTE, navOptions)
}
/**
* Add the enterprise sign on screen to the nav graph.
*/
fun NavGraphBuilder.enterpriseSignOnDestination(
onNavigateBack: () -> Unit,
) {
composable(
route = ENTERPRISE_SIGN_ON_ROUTE,
enterTransition = TransitionProviders.Enter.slideUp,
exitTransition = TransitionProviders.Exit.stay,
popEnterTransition = TransitionProviders.Enter.stay,
popExitTransition = TransitionProviders.Exit.slideDown,
) {
EnterpriseSignOnScreen(
onNavigateBack = onNavigateBack,
)
}
}

View file

@ -0,0 +1,166 @@
package com.x8bit.bitwarden.ui.auth.feature.enterprisesignon
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextAlign
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.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
/**
* The top level composable for the Enterprise Single Sign On screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EnterpriseSignOnScreen(
onNavigateBack: () -> Unit,
viewModel: EnterpriseSignOnViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
EnterpriseSignOnEvent.NavigateBack -> onNavigateBack()
is EnterpriseSignOnEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
}
}
when (val dialog = state.dialogState) {
is EnterpriseSignOnState.DialogState.Error -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = dialog.message,
),
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(EnterpriseSignOnAction.DialogDismiss) }
},
)
}
is EnterpriseSignOnState.DialogState.Loading -> {
BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(
text = dialog.message,
),
)
}
null -> Unit
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.app_name),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(EnterpriseSignOnAction.CloseButtonClick) }
},
actions = {
BitwardenTextButton(
label = stringResource(id = R.string.log_in),
onClick = remember(viewModel) {
{ viewModel.trySendAction(EnterpriseSignOnAction.LogInClick) }
},
)
},
)
},
) { innerPadding ->
EnterpriseSignOnScreenContent(
state = state,
onOrgIdentifierInputChange = remember(viewModel) {
{ viewModel.trySendAction(EnterpriseSignOnAction.OrgIdentifierInputChange(it)) }
},
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun EnterpriseSignOnScreenContent(
state: EnterpriseSignOnState,
onOrgIdentifierInputChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.semantics { testTagsAsResourceId = true }
.imePadding()
.verticalScroll(rememberScrollState())
.fillMaxWidth(),
) {
Text(
text = stringResource(id = R.string.log_in_sso_summary),
textAlign = TextAlign.Start,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
value = state.orgIdentifierInput,
onValueChange = onOrgIdentifierInputChange,
label = stringResource(id = R.string.org_identifier),
)
Spacer(modifier = Modifier.height(16.dp))
}
}

View file

@ -0,0 +1,131 @@
package com.x8bit.bitwarden.ui.auth.feature.enterprisesignon
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Manages application state for the enterprise single sign on screen.
*/
@HiltViewModel
class EnterpriseSignOnViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<EnterpriseSignOnState, EnterpriseSignOnEvent, EnterpriseSignOnAction>(
initialState = savedStateHandle[KEY_STATE]
?: EnterpriseSignOnState(
dialogState = null,
orgIdentifierInput = "",
),
) {
override fun handleAction(action: EnterpriseSignOnAction) {
when (action) {
EnterpriseSignOnAction.CloseButtonClick -> handleCloseButtonClicked()
EnterpriseSignOnAction.DialogDismiss -> handleDialogDismissed()
EnterpriseSignOnAction.LogInClick -> handleLogInClicked()
is EnterpriseSignOnAction.OrgIdentifierInputChange -> {
handleOrgIdentifierInputChanged(action)
}
}
}
private fun handleCloseButtonClicked() {
sendEvent(EnterpriseSignOnEvent.NavigateBack)
}
private fun handleDialogDismissed() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleLogInClicked() {
// TODO BIT-816: submit request for single sign on
sendEvent(EnterpriseSignOnEvent.ShowToast("Not yet implemented."))
}
private fun handleOrgIdentifierInputChanged(
action: EnterpriseSignOnAction.OrgIdentifierInputChange,
) {
mutableStateFlow.update { it.copy(orgIdentifierInput = action.input) }
}
}
/**
* Models state of the enterprise sign on screen.
*/
@Parcelize
data class EnterpriseSignOnState(
val dialogState: DialogState?,
val orgIdentifierInput: String,
) : Parcelable {
/**
* Represents the current state of any dialogs on the screen.
*/
sealed class DialogState : Parcelable {
/**
* Represents an error dialog with the given [message].
*/
@Parcelize
data class Error(
val message: Text,
) : DialogState()
/**
* Represents a loading dialog with the given [message].
*/
@Parcelize
data class Loading(
val message: Text,
) : DialogState()
}
}
/**
* Models events for the enterprise sign on screen.
*/
sealed class EnterpriseSignOnEvent {
/**
* Navigates back to the previous screen.
*/
data object NavigateBack : EnterpriseSignOnEvent()
/**
* Shows a toast with the given [message].
*/
data class ShowToast(
val message: String,
) : EnterpriseSignOnEvent()
}
/**
* Models actions for the enterprise sign on screen.
*/
sealed class EnterpriseSignOnAction {
/**
* Indicates that the top-bar close button was clicked.
*/
data object CloseButtonClick : EnterpriseSignOnAction()
/**
* Indicates that the current dialog has been dismissed.
*/
data object DialogDismiss : EnterpriseSignOnAction()
/**
* Indicates that the Log In button has been clicked.
*/
data object LogInClick : EnterpriseSignOnAction()
/**
* Indicates that the organization identifier input has changed.
*/
data class OrgIdentifierInputChange(
val input: String,
) : EnterpriseSignOnAction()
}

View file

@ -44,6 +44,7 @@ fun NavController.navigateToLogin(
*/
fun NavGraphBuilder.loginDestination(
onNavigateBack: () -> Unit,
onNavigateToEnterpriseSignOn: () -> Unit,
) {
composable(
route = LOGIN_ROUTE,
@ -55,12 +56,13 @@ fun NavGraphBuilder.loginDestination(
},
),
enterTransition = TransitionProviders.Enter.slideUp,
exitTransition = TransitionProviders.Exit.slideDown,
popEnterTransition = TransitionProviders.Enter.slideUp,
exitTransition = TransitionProviders.Exit.stay,
popEnterTransition = TransitionProviders.Enter.stay,
popExitTransition = TransitionProviders.Exit.slideDown,
) {
LoginScreen(
onNavigateBack = onNavigateBack,
onNavigateToEnterpriseSignOn = onNavigateToEnterpriseSignOn,
)
}
}

View file

@ -61,6 +61,7 @@ import kotlinx.collections.immutable.toImmutableList
@Suppress("LongMethod")
fun LoginScreen(
onNavigateBack: () -> Unit,
onNavigateToEnterpriseSignOn: () -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
intentHandler: IntentHandler = IntentHandler(context = LocalContext.current),
) {
@ -73,6 +74,7 @@ fun LoginScreen(
intentHandler.startCustomTabsActivity(uri = event.uri)
}
LoginEvent.NavigateToEnterpriseSignOn -> onNavigateToEnterpriseSignOn()
is LoginEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
@ -164,7 +166,7 @@ fun LoginScreen(
}
}
@Suppress("LongMethod")
@Suppress("LongMethod", "LongParameterList")
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun LoginScreenContent(
@ -233,13 +235,12 @@ private fun LoginScreenContent(
BitwardenOutlinedButtonWithIcon(
label = stringResource(id = R.string.log_in_sso),
icon = painterResource(id = R.drawable.ic_light_bulb),
icon = painterResource(id = R.drawable.ic_briefcase),
onClick = onSingleSignOnClick,
modifier = Modifier
.semantics { testTag = "LogInWithSsoButton" }
.fillMaxWidth()
.padding(bottom = 24.dp),
isEnabled = state.isLoginButtonEnabled,
)
Text(

View file

@ -204,8 +204,7 @@ class LoginViewModel @Inject constructor(
}
private fun handleSingleSignOnClicked() {
// TODO BIT-204 navigate to single sign on
sendEvent(LoginEvent.ShowToast("Not yet implemented."))
sendEvent(LoginEvent.NavigateToEnterpriseSignOn)
}
private fun handlePasswordInputChanged(action: LoginAction.PasswordInputChanged) {
@ -242,6 +241,11 @@ sealed class LoginEvent {
*/
data class NavigateToCaptcha(val uri: Uri) : LoginEvent()
/**
* Navigates to the enterprise single sign on screen.
*/
data object NavigateToEnterpriseSignOn : LoginEvent()
/**
* Shows a toast with the given [message].
*/

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportHeight="18"
android:viewportWidth="18">
<group>
<clip-path android:pathData="M1.5,1.5h15v15h-15z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M7.125,4.313V5.25H10.875V4.313H7.125ZM6.188,3.844V5.25H1.969C1.71,5.25 1.5,5.46 1.5,5.719V15.094C1.5,15.353 1.71,15.563 1.969,15.563H16.031C16.29,15.563 16.5,15.353 16.5,15.094V5.719C16.5,5.46 16.29,5.25 16.031,5.25H11.813V3.844C11.813,3.585 11.603,3.375 11.344,3.375H6.656C6.397,3.375 6.188,3.585 6.188,3.844ZM15.563,7.125V6.188H2.438V7.125C2.438,8.161 3.277,9 4.313,9H7.125V8.531C7.125,8.272 7.335,8.063 7.594,8.063H10.406C10.665,8.063 10.875,8.272 10.875,8.531V9H13.688C14.723,9 15.563,8.161 15.563,7.125ZM10.875,9.938V10.406C10.875,10.665 10.665,10.875 10.406,10.875H7.594C7.335,10.875 7.125,10.665 7.125,10.406V9.938H4.313C3.592,9.938 2.935,9.667 2.438,9.221V14.625H15.563V9.221C15.065,9.667 14.408,9.938 13.688,9.938H10.875ZM8.063,9H9.938V9.938H8.063V9Z" />
</group>
</vector>

View file

@ -0,0 +1,159 @@
package com.x8bit.bitwarden.ui.auth.feature.enterprisesignon
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
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 junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
class EnterpriseSignOnScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = MutableSharedFlow<EnterpriseSignOnEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<EnterpriseSignOnViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
composeTestRule.setContent {
EnterpriseSignOnScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `app bar log in click should send LogInClick action`() {
composeTestRule.onNodeWithText("Log In").performClick()
verify { viewModel.trySendAction(EnterpriseSignOnAction.LogInClick) }
}
@Test
fun `close button click should send CloseButtonClick action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
verify {
viewModel.trySendAction(EnterpriseSignOnAction.CloseButtonClick)
}
}
@Test
fun `organization identifier input change should send OrgIdentifierInputChange action`() {
val input = "input"
composeTestRule.onNodeWithText("Organization identifier").performTextInput(input)
verify {
viewModel.trySendAction(EnterpriseSignOnAction.OrgIdentifierInputChange(input))
}
}
@Test
fun `organization identifier should change according to state`() {
composeTestRule
.onNodeWithText("Organization identifier")
.assertTextEquals("Organization identifier", "")
mutableStateFlow.update { it.copy(orgIdentifierInput = "test") }
composeTestRule
.onNodeWithText("Organization identifier")
.assertTextEquals("Organization identifier", "test")
}
@Test
fun `NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(EnterpriseSignOnEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `error dialog should be shown or hidden according to the state`() {
composeTestRule.onNode(isDialog()).assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
message = "Error dialog message".asText(),
),
)
}
composeTestRule.onNode(isDialog()).assertIsDisplayed()
composeTestRule
.onNodeWithText("An error has occurred.")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Error dialog message")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Ok")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Test
fun `loading dialog should be displayed according to state`() {
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText("Loading").assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(
message = "Loading".asText(),
),
)
}
composeTestRule
.onNodeWithText("Loading")
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
}
@Test
fun `error dialog OK click should send DialogDismiss action`() {
mutableStateFlow.update {
DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
message = "message".asText(),
),
)
}
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(EnterpriseSignOnAction.DialogDismiss) }
}
companion object {
private val DEFAULT_STATE = EnterpriseSignOnState(
dialogState = null,
orgIdentifierInput = "",
)
}
}

View file

@ -0,0 +1,129 @@
package com.x8bit.bitwarden.ui.auth.feature.enterprisesignon
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
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.Test
class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
private val savedStateHandle = SavedStateHandle()
@Test
fun `initial state should be correct when not pulling from handle`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@Test
fun `initial state should pull from handle when present`() = runTest {
val expectedState = DEFAULT_STATE.copy(
orgIdentifierInput = "test",
)
val viewModel = createViewModel(expectedState)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `CloseButtonClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(EnterpriseSignOnAction.CloseButtonClick)
assertEquals(
EnterpriseSignOnEvent.NavigateBack,
awaitItem(),
)
}
}
@Test
fun `LogInClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(EnterpriseSignOnAction.LogInClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
assertEquals(
EnterpriseSignOnEvent.ShowToast("Not yet implemented."),
awaitItem(),
)
}
}
@Test
fun `OrgIdentifierInputChange should update organization identifier`() = runTest {
val input = "input"
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(EnterpriseSignOnAction.OrgIdentifierInputChange(input))
assertEquals(
DEFAULT_STATE.copy(orgIdentifierInput = input),
viewModel.stateFlow.value,
)
}
}
@Test
fun `DialogDismiss should clear the active dialog when DialogState is Error`() {
val initialState = DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
message = "Error".asText(),
),
)
val viewModel = createViewModel(initialState)
assertEquals(
initialState,
viewModel.stateFlow.value,
)
viewModel.trySendAction(EnterpriseSignOnAction.DialogDismiss)
assertEquals(
initialState.copy(dialogState = null),
viewModel.stateFlow.value,
)
}
@Test
fun `DialogDismiss should clear the active dialog when DialogState is Loading`() {
val initialState = DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(
message = "Loading".asText(),
),
)
val viewModel = createViewModel(initialState)
assertEquals(
initialState,
viewModel.stateFlow.value,
)
viewModel.trySendAction(EnterpriseSignOnAction.DialogDismiss)
assertEquals(
initialState.copy(dialogState = null),
viewModel.stateFlow.value,
)
}
private fun createViewModel(
initialState: EnterpriseSignOnState? = null,
savedStateHandle: SavedStateHandle = SavedStateHandle(
initialState = mapOf("state" to initialState),
),
): EnterpriseSignOnViewModel = EnterpriseSignOnViewModel(
savedStateHandle = savedStateHandle,
)
companion object {
private val DEFAULT_STATE = EnterpriseSignOnState(
dialogState = null,
orgIdentifierInput = "",
)
}
}

View file

@ -45,6 +45,7 @@ class LoginScreenTest : BaseComposeTest() {
every { startCustomTabsActivity(any()) } returns Unit
}
private var onNavigateBackCalled = false
private var onNavigateToEnterpriseSignOnCalled = false
private val mutableEventFlow = MutableSharedFlow<LoginEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
@ -59,6 +60,7 @@ class LoginScreenTest : BaseComposeTest() {
composeTestRule.setContent {
LoginScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToEnterpriseSignOn = { onNavigateToEnterpriseSignOnCalled = true },
viewModel = viewModel,
intentHandler = intentHandler,
)
@ -265,6 +267,12 @@ class LoginScreenTest : BaseComposeTest() {
mutableEventFlow.tryEmit(LoginEvent.NavigateToCaptcha(mockUri))
verify { intentHandler.startCustomTabsActivity(mockUri) }
}
@Test
fun `NavigateToEnterpriseSignOn should call onNavigateToEnterpriseSignOn`() {
mutableEventFlow.tryEmit(LoginEvent.NavigateToEnterpriseSignOn)
assertTrue(onNavigateToEnterpriseSignOnCalled)
}
}
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(

View file

@ -318,13 +318,13 @@ class LoginViewModelTest : BaseViewModelTest() {
}
@Test
fun `SingleSignOnClick should emit ShowToast`() = runTest {
fun `SingleSignOnClick should emit NavigateToEnterpriseSignOn`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LoginAction.SingleSignOnClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
assertEquals(
LoginEvent.ShowToast("Not yet implemented."),
LoginEvent.NavigateToEnterpriseSignOn,
awaitItem(),
)
}