Improve APIs, add tests

This commit is contained in:
Jorge Martín
2024-08-12 12:22:36 +02:00
parent 252b3e11a0
commit 903d24ea2f
22 changed files with 562 additions and 76 deletions

View File

@@ -19,7 +19,6 @@ package io.element.android.features.securebackup.impl.reset
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
@@ -46,10 +45,6 @@ class ResetIdentityFlowManager @Inject constructor(
}
}
fun currentSessionId(): SessionId {
return matrixClient.sessionId
}
fun getResetHandle(): StateFlow<AsyncData<IdentityResetHandle>> {
return if (resetHandleFlow.value.isLoading() || resetHandleFlow.value.isSuccess()) {
resetHandleFlow

View File

@@ -34,8 +34,8 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.securebackup.impl.reset.password.ResetKeyPasswordNode
import io.element.android.features.securebackup.impl.reset.root.ResetKeyRootNode
import io.element.android.features.securebackup.impl.reset.password.ResetIdentityPasswordNode
import io.element.android.features.securebackup.impl.reset.root.ResetIdentityRootNode
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.BackstackView
@@ -108,18 +108,18 @@ class ResetIdentityFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.Root -> {
val callback = object : ResetKeyRootNode.Callback {
val callback = object : ResetIdentityRootNode.Callback {
override fun onContinue() {
coroutineScope.startReset()
}
}
createNode<ResetKeyRootNode>(buildContext, listOf(callback))
createNode<ResetIdentityRootNode>(buildContext, listOf(callback))
}
is NavTarget.ResetPassword -> {
val handle = resetIdentityFlowManager.currentHandleFlow.value.dataOrNull() as? IdentityPasswordResetHandle ?: error("No password handle found")
createNode<ResetKeyPasswordNode>(
createNode<ResetIdentityPasswordNode>(
buildContext,
listOf(ResetKeyPasswordNode.Inputs(resetIdentityFlowManager.currentSessionId(), handle))
listOf(ResetIdentityPasswordNode.Inputs(handle))
)
}
is NavTarget.ResetOidc -> {

View File

@@ -16,7 +16,7 @@
package io.element.android.features.securebackup.impl.reset.password
sealed interface ResetKeyPasswordEvent {
data class Reset(val password: String) : ResetKeyPasswordEvent
data object DismissError : ResetKeyPasswordEvent
sealed interface ResetIdentityPasswordEvent {
data class Reset(val password: String) : ResetIdentityPasswordEvent
data object DismissError : ResetIdentityPasswordEvent
}

View File

@@ -21,33 +21,33 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
@ContributesNode(SessionScope::class)
class ResetKeyPasswordNode @AssistedInject constructor(
class ResetIdentityPasswordNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val coroutineDispatchers: CoroutineDispatchers,
) : Node(buildContext, plugins = plugins) {
data class Inputs(val userId: UserId, val handle: IdentityPasswordResetHandle) : NodeInputs
data class Inputs(val handle: IdentityPasswordResetHandle) : NodeInputs
private val presenter by lazy {
val inputs = inputs<Inputs>()
ResetKeyPasswordPresenter(inputs.userId, inputs.handle)
ResetIdentityPasswordPresenter(inputs.handle, dispatchers = coroutineDispatchers)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
ResetKeyPasswordView(
ResetIdentityPasswordView(
state = state,
onBack = ::navigateUp
)

View File

@@ -24,37 +24,37 @@ import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class ResetKeyPasswordPresenter(
private val userId: UserId,
class ResetIdentityPasswordPresenter(
private val identityPasswordResetHandle: IdentityPasswordResetHandle,
) : Presenter<ResetKeyPasswordState> {
private val dispatchers: CoroutineDispatchers,
) : Presenter<ResetIdentityPasswordState> {
@Composable
override fun present(): ResetKeyPasswordState {
override fun present(): ResetIdentityPasswordState {
val coroutineScope = rememberCoroutineScope()
val resetAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
fun handleEvent(event: ResetKeyPasswordEvent) {
fun handleEvent(event: ResetIdentityPasswordEvent) {
when (event) {
is ResetKeyPasswordEvent.Reset -> coroutineScope.reset(userId, event.password, resetAction)
ResetKeyPasswordEvent.DismissError -> resetAction.value = AsyncAction.Uninitialized
is ResetIdentityPasswordEvent.Reset -> coroutineScope.reset(event.password, resetAction)
ResetIdentityPasswordEvent.DismissError -> resetAction.value = AsyncAction.Uninitialized
}
}
return ResetKeyPasswordState(
return ResetIdentityPasswordState(
resetAction = resetAction.value,
eventSink = ::handleEvent
)
}
private fun CoroutineScope.reset(userId: UserId, password: String, action: MutableState<AsyncAction<Unit>>) = launch {
private fun CoroutineScope.reset(password: String, action: MutableState<AsyncAction<Unit>>) = launch(dispatchers.io) {
suspend {
identityPasswordResetHandle.resetPassword(userId, password).getOrThrow()
identityPasswordResetHandle.resetPassword(password).getOrThrow()
}.runCatchingUpdatingState(action)
}
}

View File

@@ -18,7 +18,7 @@ package io.element.android.features.securebackup.impl.reset.password
import io.element.android.libraries.architecture.AsyncAction
data class ResetKeyPasswordState(
data class ResetIdentityPasswordState(
val resetAction: AsyncAction<Unit>,
val eventSink: (ResetKeyPasswordEvent) -> Unit,
val eventSink: (ResetIdentityPasswordEvent) -> Unit,
)

View File

@@ -46,8 +46,8 @@ import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKe
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ResetKeyPasswordView(
state: ResetKeyPasswordState,
fun ResetIdentityPasswordView(
state: ResetIdentityPasswordState,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -63,7 +63,7 @@ fun ResetKeyPasswordView(
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_reset_identity),
onClick = { state.eventSink(ResetKeyPasswordEvent.Reset(passwordState.value)) },
onClick = { state.eventSink(ResetIdentityPasswordEvent.Reset(passwordState.value)) },
destructive = true,
)
}
@@ -74,7 +74,7 @@ fun ResetKeyPasswordView(
} else if (state.resetAction.isFailure()) {
ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onDismiss = { state.eventSink(ResetKeyPasswordEvent.DismissError) }
onDismiss = { state.eventSink(ResetIdentityPasswordEvent.DismissError) }
)
}
}
@@ -107,10 +107,10 @@ private fun Content(textFieldState: MutableState<String>) {
@PreviewsDayNight
@Composable
internal fun ResetKeyPasswordViewPreview() {
internal fun ResetIdentityPasswordViewPreview() {
ElementPreview {
ResetKeyPasswordView(
state = ResetKeyPasswordState(
ResetIdentityPasswordView(
state = ResetIdentityPasswordState(
resetAction = AsyncAction.Uninitialized,
eventSink = {}
),

View File

@@ -16,7 +16,7 @@
package io.element.android.features.securebackup.impl.reset.root
sealed interface ResetKeyRootEvent {
data object Continue : ResetKeyRootEvent
data object DismissDialog : ResetKeyRootEvent
sealed interface ResetIdentityRootEvent {
data object Continue : ResetIdentityRootEvent
data object DismissDialog : ResetIdentityRootEvent
}

View File

@@ -27,7 +27,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class ResetKeyRootNode @AssistedInject constructor(
class ResetIdentityRootNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins) {
@@ -35,13 +35,13 @@ class ResetKeyRootNode @AssistedInject constructor(
fun onContinue()
}
private val presenter = ResetKeyRootPresenter()
private val presenter = ResetIdentityRootPresenter()
private val callback: Callback = plugins.filterIsInstance<Callback>().first()
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
ResetKeyRootView(
ResetIdentityRootView(
state = state,
onContinue = callback::onContinue,
onBack = ::navigateUp,

View File

@@ -23,19 +23,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
class ResetKeyRootPresenter : Presenter<ResetKeyRootState> {
class ResetIdentityRootPresenter : Presenter<ResetIdentityRootState> {
@Composable
override fun present(): ResetKeyRootState {
override fun present(): ResetIdentityRootState {
var displayConfirmDialog by remember { mutableStateOf(false) }
fun handleEvent(event: ResetKeyRootEvent) {
fun handleEvent(event: ResetIdentityRootEvent) {
displayConfirmDialog = when (event) {
ResetKeyRootEvent.Continue -> true
ResetKeyRootEvent.DismissDialog -> false
ResetIdentityRootEvent.Continue -> true
ResetIdentityRootEvent.DismissDialog -> false
}
}
return ResetKeyRootState(
return ResetIdentityRootState(
displayConfirmationDialog = displayConfirmDialog,
eventSink = ::handleEvent
)

View File

@@ -16,7 +16,7 @@
package io.element.android.features.securebackup.impl.reset.root
data class ResetKeyRootState(
data class ResetIdentityRootState(
val displayConfirmationDialog: Boolean,
val eventSink: (ResetKeyRootEvent) -> Unit,
val eventSink: (ResetIdentityRootEvent) -> Unit,
)

View File

@@ -18,14 +18,14 @@ package io.element.android.features.securebackup.impl.reset.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class ResetKeyRootStateProvider : PreviewParameterProvider<ResetKeyRootState> {
override val values: Sequence<ResetKeyRootState>
class ResetIdentityRootStateProvider : PreviewParameterProvider<ResetIdentityRootState> {
override val values: Sequence<ResetIdentityRootState>
get() = sequenceOf(
ResetKeyRootState(
ResetIdentityRootState(
displayConfirmationDialog = false,
eventSink = {}
),
ResetKeyRootState(
ResetIdentityRootState(
displayConfirmationDialog = true,
eventSink = {}
)

View File

@@ -43,8 +43,8 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable
fun ResetKeyRootView(
state: ResetKeyRootState,
fun ResetIdentityRootView(
state: ResetIdentityRootState,
onContinue: () -> Unit,
onBack: () -> Unit,
) {
@@ -58,7 +58,7 @@ fun ResetKeyRootView(
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = CommonStrings.action_continue),
onClick = { state.eventSink(ResetKeyRootEvent.Continue) },
onClick = { state.eventSink(ResetIdentityRootEvent.Continue) },
destructive = true,
)
},
@@ -71,11 +71,11 @@ fun ResetKeyRootView(
content = stringResource(CommonStrings.screen_reset_encryption_confirmation_alert_subtitle),
submitText = stringResource(CommonStrings.screen_reset_encryption_confirmation_alert_action),
onSubmitClick = {
state.eventSink(ResetKeyRootEvent.DismissDialog)
state.eventSink(ResetIdentityRootEvent.DismissDialog)
onContinue()
},
destructiveSubmit = true,
onDismiss = { state.eventSink(ResetKeyRootEvent.DismissDialog) }
onDismiss = { state.eventSink(ResetIdentityRootEvent.DismissDialog) }
)
}
}
@@ -138,9 +138,9 @@ private fun Content() {
@PreviewsDayNight
@Composable
internal fun ResetKeyRootViewPreview(@PreviewParameter(ResetKeyRootStateProvider::class) state: ResetKeyRootState) {
internal fun ResetIdentityRootViewPreview(@PreviewParameter(ResetIdentityRootStateProvider::class) state: ResetIdentityRootState) {
ElementPreview {
ResetKeyRootView(
ResetIdentityRootView(
state = state,
onContinue = {},
onBack = {},

View File

@@ -0,0 +1,119 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.securebackup.impl.reset
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.encryption.FakeIdentityPasswordResetHandle
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ResetIdentityFlowManagerTests {
@Test
fun `getResetHandle - emits a reset handle`() = runTest {
val startResetLambda = lambdaRecorder<Result<IdentityResetHandle?>> { Result.success(FakeIdentityPasswordResetHandle()) }
val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
val flowManager = createFlowManager(encryptionService = encryptionService)
flowManager.getResetHandle().test {
assertThat(awaitItem().isLoading()).isTrue()
assertThat(awaitItem().isSuccess()).isTrue()
startResetLambda.assertions().isCalledOnce()
}
}
@Test
fun `getResetHandle - om successful handle retrieval returns that same handle`() = runTest {
val startResetLambda = lambdaRecorder<Result<IdentityResetHandle?>> { Result.success(FakeIdentityPasswordResetHandle()) }
val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
val flowManager = createFlowManager(encryptionService = encryptionService)
var result: AsyncData.Success<IdentityResetHandle>? = null
flowManager.getResetHandle().test {
assertThat(awaitItem().isLoading()).isTrue()
result = awaitItem() as? AsyncData.Success<IdentityResetHandle>
assertThat(result).isNotNull()
}
flowManager.getResetHandle().test {
assertThat(awaitItem()).isSameInstanceAs(result)
}
}
@Test
fun `getResetHandle - will fail if it receives a null reset handle`() = runTest {
val startResetLambda = lambdaRecorder<Result<IdentityResetHandle?>> { Result.success(null) }
val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
val flowManager = createFlowManager(encryptionService = encryptionService)
flowManager.getResetHandle().test {
assertThat(awaitItem().isLoading()).isTrue()
assertThat(awaitItem().isFailure()).isTrue()
startResetLambda.assertions().isCalledOnce()
}
}
@Test
fun `getResetHandle - fails gracefully when receiving an exception from the encryption service`() = runTest {
val startResetLambda = lambdaRecorder<Result<IdentityResetHandle?>> { Result.failure(IllegalStateException("Failure")) }
val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
val flowManager = createFlowManager(encryptionService = encryptionService)
flowManager.getResetHandle().test {
assertThat(awaitItem().isLoading()).isTrue()
assertThat(awaitItem().isFailure()).isTrue()
startResetLambda.assertions().isCalledOnce()
}
}
@Test
fun `cancel - resets the state and calls cancel on the reset handle`() = runTest {
val cancelLambda = lambdaRecorder<Unit> { }
val resetHandle = FakeIdentityPasswordResetHandle(cancelLambda = cancelLambda)
val startResetLambda = lambdaRecorder<Result<IdentityResetHandle?>> { Result.success(resetHandle) }
val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
val flowManager = createFlowManager(encryptionService = encryptionService)
flowManager.getResetHandle().test {
assertThat(awaitItem().isLoading()).isTrue()
assertThat(awaitItem().isSuccess()).isTrue()
flowManager.cancel()
cancelLambda.assertions().isCalledOnce()
assertThat(awaitItem().isUninitialized()).isTrue()
}
}
private fun TestScope.createFlowManager(
encryptionService: FakeEncryptionService = FakeEncryptionService(),
client: FakeMatrixClient = FakeMatrixClient(encryptionService = encryptionService),
sessionCoroutineScope: CoroutineScope = this,
sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
) = ResetIdentityFlowManager(
matrixClient = client,
sessionCoroutineScope = sessionCoroutineScope,
sessionVerificationService = sessionVerificationService,
)
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.securebackup.impl.reset.password
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.encryption.FakeIdentityPasswordResetHandle
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ResetIdentityPasswordPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.resetAction.isUninitialized()).isTrue()
}
}
@Test
fun `present - Reset event succeeds`() = runTest {
val resetLambda = lambdaRecorder<String, Result<Unit>> { _ -> Result.success(Unit) }
val resetHandle = FakeIdentityPasswordResetHandle(resetPasswordLambda = resetLambda)
val presenter = createPresenter(identityResetHandle = resetHandle)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ResetIdentityPasswordEvent.Reset("password"))
assertThat(awaitItem().resetAction.isLoading()).isTrue()
assertThat(awaitItem().resetAction.isSuccess()).isTrue()
}
}
@Test
fun `present - Reset event can fail gracefully`() = runTest {
val resetLambda = lambdaRecorder<String, Result<Unit>> { _ -> Result.failure(IllegalStateException("Failed")) }
val resetHandle = FakeIdentityPasswordResetHandle(resetPasswordLambda = resetLambda)
val presenter = createPresenter(identityResetHandle = resetHandle)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ResetIdentityPasswordEvent.Reset("password"))
assertThat(awaitItem().resetAction.isLoading()).isTrue()
assertThat(awaitItem().resetAction.isFailure()).isTrue()
}
}
@Test
fun `present - DismissError event resets the state`() = runTest {
val resetLambda = lambdaRecorder<String, Result<Unit>> { _ -> Result.failure(IllegalStateException("Failed")) }
val resetHandle = FakeIdentityPasswordResetHandle(resetPasswordLambda = resetLambda)
val presenter = createPresenter(identityResetHandle = resetHandle)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ResetIdentityPasswordEvent.Reset("password"))
assertThat(awaitItem().resetAction.isLoading()).isTrue()
assertThat(awaitItem().resetAction.isFailure()).isTrue()
initialState.eventSink(ResetIdentityPasswordEvent.DismissError)
assertThat(awaitItem().resetAction.isUninitialized()).isTrue()
}
}
private fun TestScope.createPresenter(
identityResetHandle: FakeIdentityPasswordResetHandle = FakeIdentityPasswordResetHandle(),
) = ResetIdentityPasswordPresenter(
identityPasswordResetHandle = identityResetHandle,
dispatchers = testCoroutineDispatchers(),
)
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.securebackup.impl.reset.password
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ResetIdentityPasswordViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `pressing the back HW button invokes the expected callback`() {
ensureCalledOnce {
rule.setResetPasswordView(
ResetIdentityPasswordState(resetAction = AsyncAction.Uninitialized, eventSink = {}),
onBack = it,
)
rule.pressBackKey()
}
}
@Test
fun `clicking on the back navigation button invokes the expected callback`() {
ensureCalledOnce {
rule.setResetPasswordView(
ResetIdentityPasswordState(resetAction = AsyncAction.Uninitialized, eventSink = {}),
onBack = it,
)
rule.pressBack()
}
}
@Test
fun `clicking 'Reset identity' confirms the reset`() {
val eventsRecorder = EventsRecorder<ResetIdentityPasswordEvent>()
rule.setResetPasswordView(
ResetIdentityPasswordState(resetAction = AsyncAction.Uninitialized, eventSink = eventsRecorder),
)
rule.onNodeWithText("Password").performTextInput("A password")
rule.clickOn(CommonStrings.action_reset_identity)
eventsRecorder.assertSingle(ResetIdentityPasswordEvent.Reset("A password"))
}
@Test
fun `clicking OK dismisses the error dialog`() {
val eventsRecorder = EventsRecorder<ResetIdentityPasswordEvent>()
rule.setResetPasswordView(
ResetIdentityPasswordState(resetAction = AsyncAction.Failure(IllegalStateException("A failure")), eventSink = eventsRecorder),
)
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(ResetIdentityPasswordEvent.DismissError)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResetPasswordView(
state: ResetIdentityPasswordState,
onBack: () -> Unit = EnsureNeverCalled(),
) {
setContent {
ResetIdentityPasswordView(state = state, onBack = onBack)
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.securebackup.impl.reset.root
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ResetIdentityRootPresenterTests {
@Test
fun `present - initial state`() = runTest {
val presenter = ResetIdentityRootPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.displayConfirmationDialog).isFalse()
}
}
@Test
fun `present - Continue event displays the confirmation dialog`() = runTest {
val presenter = ResetIdentityRootPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ResetIdentityRootEvent.Continue)
assertThat(awaitItem().displayConfirmationDialog).isTrue()
}
}
@Test
fun `present - DismissDialog event hides the confirmation dialog`() = runTest {
val presenter = ResetIdentityRootPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ResetIdentityRootEvent.Continue)
assertThat(awaitItem().displayConfirmationDialog).isTrue()
initialState.eventSink(ResetIdentityRootEvent.DismissDialog)
assertThat(awaitItem().displayConfirmationDialog).isFalse()
}
}
}

View File

@@ -0,0 +1,107 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.securebackup.impl.reset.root
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class ResetIdentityRootViewTests {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `pressing the back HW button invokes the expected callback`() {
ensureCalledOnce {
rule.setResetRootView(
ResetIdentityRootState(displayConfirmationDialog = false, eventSink = {}),
onBack = it,
)
rule.pressBackKey()
}
}
@Test
fun `clicking on the back navigation button invokes the expected callback`() {
ensureCalledOnce {
rule.setResetRootView(
ResetIdentityRootState(displayConfirmationDialog = false, eventSink = {}),
onBack = it,
)
rule.pressBack()
}
}
@Test
@Config(qualifiers = "h720dp")
fun `clicking Continue displays the confirmation dialog`() {
val eventsRecorder = EventsRecorder<ResetIdentityRootEvent>()
rule.setResetRootView(
ResetIdentityRootState(displayConfirmationDialog = false, eventSink = eventsRecorder),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ResetIdentityRootEvent.Continue)
}
@Test
fun `clicking 'Yes, reset now' confirms the reset`() {
ensureCalledOnce {
rule.setResetRootView(
ResetIdentityRootState(displayConfirmationDialog = true, eventSink = {}),
onContinue = it,
)
rule.clickOn(CommonStrings.screen_reset_encryption_confirmation_alert_action)
}
}
@Test
fun `clicking Cancel dismisses the dialog`() {
val eventsRecorder = EventsRecorder<ResetIdentityRootEvent>()
rule.setResetRootView(
ResetIdentityRootState(displayConfirmationDialog = true, eventSink = eventsRecorder),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ResetIdentityRootEvent.DismissDialog)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResetRootView(
state: ResetIdentityRootState,
onBack: () -> Unit = EnsureNeverCalled(),
onContinue: () -> Unit = EnsureNeverCalled(),
) {
setContent {
ResetIdentityRootView(state = state, onContinue = onContinue, onBack = onBack)
}
}