Improve APIs, add tests
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = {}
|
||||
),
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = {}
|
||||
)
|
||||
@@ -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 = {},
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user