diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 45b294b04c..6cb11dceb2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -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() }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnNavigation.kt new file mode 100644 index 0000000000..cdb07660e7 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnNavigation.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt new file mode 100644 index 0000000000..0bdd212788 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt @@ -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)) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt new file mode 100644 index 0000000000..0287a771ea --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt @@ -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( + 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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt index f7015bc53f..f192dd16a4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt @@ -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, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt index 275d991d61..3fc8b7ea06 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt index 887a7c7ada..06f4bb20e3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt @@ -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]. */ diff --git a/app/src/main/res/drawable/ic_briefcase.xml b/app/src/main/res/drawable/ic_briefcase.xml new file mode 100644 index 0000000000..48c500a25c --- /dev/null +++ b/app/src/main/res/drawable/ic_briefcase.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt new file mode 100644 index 0000000000..f6eac96c62 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt @@ -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( + extraBufferCapacity = Int.MAX_VALUE, + ) + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(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 = "", + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt new file mode 100644 index 0000000000..4fcb2187e8 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt @@ -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 = "", + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt index 36cbc77315..f2c5b9b503 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt @@ -45,6 +45,7 @@ class LoginScreenTest : BaseComposeTest() { every { startCustomTabsActivity(any()) } returns Unit } private var onNavigateBackCalled = false + private var onNavigateToEnterpriseSignOnCalled = false private val mutableEventFlow = MutableSharedFlow( 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( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt index 288e05744d..3a92504674 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt @@ -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(), ) }