diff --git a/features/knockrequests/impl/build.gradle.kts b/features/knockrequests/impl/build.gradle.kts index b664a0a6b4..2664528d74 100644 --- a/features/knockrequests/impl/build.gradle.kts +++ b/features/knockrequests/impl/build.gradle.kts @@ -14,6 +14,11 @@ plugins { android { namespace = "io.element.android.features.knockrequests.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } setupAnvil() @@ -31,7 +36,12 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) testImplementation(libs.molecule.runtime) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) + testImplementation(libs.androidx.compose.ui.test.junit) + testImplementation(projects.libraries.featureflag.test) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt new file mode 100644 index 0000000000..83f0a08c5b --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.room.MatrixRoom + +@Module +@ContributesTo(RoomScope::class) +object KnockRequestsModule { + @Provides + @SingleIn(RoomScope::class) + fun knockRequestsService(room: MatrixRoom): KnockRequestsService { + return KnockRequestsService(room.knockRequestsFlow, room.roomCoroutineScope) + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt index 653930b16b..5a373f3f36 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt @@ -8,13 +8,13 @@ package io.element.android.features.knockrequests.impl.data import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.di.RoomScope -import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.knock.KnockRequest import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -22,23 +22,24 @@ import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.supervisorScope -import javax.inject.Inject -@SingleIn(RoomScope::class) -class KnockRequestsService @Inject constructor(room: MatrixRoom) { +class KnockRequestsService( + knockRequestsFlow: Flow>, + coroutineScope: CoroutineScope, +) { // Keep track of the knock requests that have been handled, so we don't have to wait for sync to remove them. private val handledKnockRequestIds = MutableStateFlow>(emptySet()) val knockRequestsFlow = combine( - room.wrappedKnockRequestsFlow(), + knockRequestsFlow.wrapped(), handledKnockRequestIds, ) { knockRequests, handledKnockIds -> val presentableKnockRequests = knockRequests .filter { it.eventId !in handledKnockIds } .toImmutableList() AsyncData.Success(presentableKnockRequests) - }.stateIn(room.roomCoroutineScope, SharingStarted.Lazily, AsyncData.Loading()) + }.stateIn(coroutineScope, SharingStarted.Lazily, AsyncData.Loading()) private fun knockRequestsList() = knockRequestsFlow.value.dataOrNull().orEmpty() @@ -129,7 +130,7 @@ class KnockRequestsService @Inject constructor(room: MatrixRoom) { private fun knockRequestNotFoundResult() = Result.failure(IllegalArgumentException("Knock request not found")) - private fun MatrixRoom.wrappedKnockRequestsFlow() = knockRequestsFlow.map { knockRequests -> + private fun Flow>.wrapped() = map { knockRequests -> knockRequests.map { KnockRequestWrapper(it) } } } diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt new file mode 100644 index 0000000000..189f392f95 --- /dev/null +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt @@ -0,0 +1,253 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.knockrequests.impl.data.KnockRequestsService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.knock.FakeKnockRequest +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) class KnockRequestsBannerPresenterTest { + + @Test + fun `present - when feature is disabled then the banner should be hidden`() = runTest { + val knockRequests = flowOf(listOf(FakeKnockRequest())) + val presenter = createKnockRequestsBannerPresenter(isFeatureEnabled = false, knockRequestsFlow = knockRequests) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + } + } + } + + @Test + fun `present - when empty knock request list then the banner should be hidden`() = runTest { + val knockRequests = flowOf(emptyList()) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + } + } + } + + @Test + fun `present - when no permission to manage knock requests then the banner should be hidden`() = runTest { + val presenter = createKnockRequestsBannerPresenter(canAcceptKnockRequests = false) + presenter.test { + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + } + } + } + + @Test + fun `present - when everything is setup to manage knocks with data, then the banner should be visible `() = runTest { + val knockRequests = flowOf( + listOf( + FakeKnockRequest( + reason = "A reason", + ) + ) + ) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + assertThat(state.knockRequests).hasSize(1) + assertThat(state.canAccept).isTrue() + assertThat(state.reason).isEqualTo("A reason") + } + } + } + + @Test + fun `present - when multiple knock requests, the banner should not have reason nor subtitle`() = runTest { + val knockRequests = flowOf( + listOf( + FakeKnockRequest( + displayName = "Alice", + ), + FakeKnockRequest( + displayName = "Bob", + ), + FakeKnockRequest( + displayName = "Charlie", + ), + ) + ) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + assertThat(state.knockRequests).hasSize(3) + assertThat(state.reason).isNull() + assertThat(state.subtitle).isNull() + } + } + } + + @Test + fun `present - when there are some seen knock requests, then the banner should filtered them`() = runTest { + val knockRequests = flowOf( + listOf( + FakeKnockRequest( + displayName = "Alice", + isSeen = true, + userId = A_USER_ID + ), + FakeKnockRequest( + displayName = "Bob", + isSeen = true, + userId = A_USER_ID_2 + ), + FakeKnockRequest( + isSeen = false, + displayName = "Charlie", + reason = "A reason", + userId = A_USER_ID_3 + ), + ) + ) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + // Only Charlie should be displayed + assertThat(state.knockRequests).hasSize(1) + assertThat(state.reason).isEqualTo("A reason") + assertThat(state.subtitle).isEqualTo(A_USER_ID_3.value) + } + } + } + + @Test + fun `present - given AcceptSingleRequest event with failure, then the banner should hide and reappear and error should appear and disappear`() = runTest { + val acceptLambda = lambdaRecorder> { Result.failure(Exception()) } + val knockRequest = FakeKnockRequest( + displayName = "Alice", + reason = "A reason", + acceptLambda = acceptLambda + ) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest) + } + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + assertThat(state.displayAcceptError).isFalse() + } + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + assertThat(state.displayAcceptError).isTrue() + } + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + assertThat(state.displayAcceptError).isTrue() + } + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + assertThat(state.displayAcceptError).isFalse() + } + assert(acceptLambda).isCalledOnce() + } + } + + @Test + fun `present - given an AcceptSingleRequest event with success, then banner should be dismissed`() = runTest { + val acceptLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequest = FakeKnockRequest( + displayName = "Alice", + reason = "A reason", + acceptLambda = acceptLambda + ) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.knockRequests).hasSize(1) + state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest) + } + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + } + advanceUntilIdle() + assert(acceptLambda).isCalledOnce() + } + } + + @Test + fun `present - given a Dismiss event, then knock requests should be marked as seen`() = runTest { + val markAsSeenLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequests = flowOf( + listOf( + FakeKnockRequest(markAsSeenLambda = markAsSeenLambda), + FakeKnockRequest(markAsSeenLambda = markAsSeenLambda), + FakeKnockRequest(markAsSeenLambda = markAsSeenLambda), + ) + ) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + state.eventSink(KnockRequestsBannerEvents.Dismiss) + } + advanceUntilIdle() + assert(markAsSeenLambda).isCalledExactly(3) + } + } +} + +private fun TestScope.createKnockRequestsBannerPresenter( + knockRequestsFlow: Flow> = flowOf(emptyList()), + canAcceptKnockRequests: Boolean = true, + isFeatureEnabled: Boolean = true, +): KnockRequestsBannerPresenter { + val knockRequestsService = KnockRequestsService( + knockRequestsFlow = knockRequestsFlow, + coroutineScope = backgroundScope + ) + val featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.Knock.key to isFeatureEnabled + ) + ) + return KnockRequestsBannerPresenter( + room = FakeMatrixRoom( + canInviteResult = { Result.success(canAcceptKnockRequests) } + ), + knockRequestsService = knockRequestsService, + appCoroutineScope = this, + featureFlagService = featureFlagService + ) +} diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerViewTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerViewTest.kt new file mode 100644 index 0000000000..7326be47b2 --- /dev/null +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerViewTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.knockrequests.impl.R +import io.element.android.features.knockrequests.impl.data.aKnockRequest +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 org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class KnockRequestsListViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on view on single request invoke the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setKnockRequestsBannerView( + state = aKnockRequestsBannerState( + eventSink = eventsRecorder, + ), + onViewRequestsClick = it + ) + rule.clickOn(R.string.screen_room_single_knock_request_view_button_title) + } + } + + @Test + fun `clicking on view all when multiple requests invoke the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setKnockRequestsBannerView( + state = aKnockRequestsBannerState( + knockRequests = listOf( + aKnockRequest(displayName = "Alice"), + aKnockRequest(displayName = "Bob"), + aKnockRequest(displayName = "Charlie") + ), + eventSink = eventsRecorder, + ), + onViewRequestsClick = it + ) + rule.clickOn(R.string.screen_room_multiple_knock_requests_view_all_button_title) + } + } + + @Test + fun `clicking on accept on a single request emit the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setKnockRequestsBannerView( + state = aKnockRequestsBannerState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_accept) + eventsRecorder.assertSingle(KnockRequestsBannerEvents.AcceptSingleRequest) + } + + @Test + fun `clicking on dismiss emit the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setKnockRequestsBannerView( + state = aKnockRequestsBannerState( + eventSink = eventsRecorder, + ), + ) + val close = rule.activity.getString(CommonStrings.action_close) + rule.onNodeWithContentDescription(close).performClick() + eventsRecorder.assertSingle(KnockRequestsBannerEvents.Dismiss) + } +} + +private fun AndroidComposeTestRule.setKnockRequestsBannerView( + state: KnockRequestsBannerState, + onViewRequestsClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + KnockRequestsBannerView( + state = state, + onViewRequestsClick = onViewRequestsClick, + ) + } +} diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt new file mode 100644 index 0000000000..1638428c79 --- /dev/null +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt @@ -0,0 +1,310 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.knockrequests.impl.list + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.knockrequests.impl.data.KnockRequestsService +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.knock.FakeKnockRequest +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class KnockRequestsListPresenterTest { + @Test + fun `present - initial states should be emitted`() = runTest { + val presenter = createKnockRequestsListPresenter() + presenter.test { + awaitItem().also { state -> + assertThat(state.knockRequests).isInstanceOf(AsyncData.Loading::class.java) + assertThat(state.canAccept).isFalse() + assertThat(state.canDecline).isFalse() + assertThat(state.canBan).isFalse() + } + awaitItem().also { state -> + assertThat(state.knockRequests).isInstanceOf(AsyncData.Loading::class.java) + assertThat(state.canAccept).isTrue() + assertThat(state.canDecline).isTrue() + assertThat(state.canBan).isTrue() + } + awaitItem().also { state -> + assertThat(state.knockRequests).isInstanceOf(AsyncData.Success::class.java) + assertThat(state.knockRequests.dataOrNull()).isEmpty() + } + } + } + + @Test + fun `present - accept success scenario`() = runTest { + val acceptLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequest = FakeKnockRequest(acceptLambda = acceptLambda) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + state.eventSink(KnockRequestsListEvents.Accept(knockRequestPresentable)) + } + skipItems(1) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.Accept(knockRequestPresentable)) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.None) + assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty() + } + assert(acceptLambda).isCalledOnce() + } + } + + @Test + fun `present - accept failure scenario`() = runTest { + val acceptLambda = lambdaRecorder> { Result.failure(Exception()) } + val knockRequest = FakeKnockRequest(acceptLambda = acceptLambda) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + state.eventSink(KnockRequestsListEvents.Accept(knockRequestPresentable)) + } + skipItems(1) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.Accept(knockRequestPresentable)) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink(KnockRequestsListEvents.RetryCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.None) + assertThat(state.knockRequests.dataOrNull()).hasSize(1) + } + assert(acceptLambda).isCalledExactly(2) + } + } + + @Test + fun `present - decline success scenario`() = runTest { + val declineLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequest = FakeKnockRequest(declineLambda = declineLambda) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + state.eventSink(KnockRequestsListEvents.Decline(knockRequestPresentable)) + } + skipItems(1) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.Decline(knockRequestPresentable)) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java) + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.None) + assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty() + } + } + assert(declineLambda).isCalledOnce() + } + + @Test + fun `present - decline and ban success scenario`() = runTest { + val declineAndBanLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequest = FakeKnockRequest(declineAndBanLambda = declineAndBanLambda) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + state.eventSink(KnockRequestsListEvents.DeclineAndBan(knockRequestPresentable)) + } + skipItems(1) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.DeclineAndBan(knockRequestPresentable)) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java) + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.None) + assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty() + } + } + assert(declineAndBanLambda).isCalledOnce() + } + + @Test + fun `present - accept all success scenario`() = runTest { + val acceptLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequests = flowOf( + listOf( + FakeKnockRequest(eventId = AN_EVENT_ID, acceptLambda = acceptLambda), + FakeKnockRequest(eventId = AN_EVENT_ID_2, acceptLambda = acceptLambda), + ) + ) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.canAcceptAll).isTrue() + state.eventSink(KnockRequestsListEvents.AcceptAll) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.AcceptAll) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java) + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.None) + assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty() + } + } + assert(acceptLambda).isCalledExactly(2) + } + + @Test + fun `present - accept all partial success scenario`() = runTest { + val acceptSuccessLambda = lambdaRecorder> { Result.success(Unit) } + val acceptFailureLambda = lambdaRecorder> { Result.failure(Exception()) } + val knockRequests = flowOf( + listOf( + FakeKnockRequest(eventId = AN_EVENT_ID, acceptLambda = acceptSuccessLambda), + FakeKnockRequest(eventId = AN_EVENT_ID_2, acceptLambda = acceptFailureLambda), + ) + ) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.canAcceptAll).isTrue() + state.eventSink(KnockRequestsListEvents.AcceptAll) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.AcceptAll) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java) + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.None) + assertThat(state.knockRequests.dataOrNull()).hasSize(1) + } + } + assert(acceptFailureLambda).isCalledOnce() + assert(acceptSuccessLambda).isCalledOnce() + } + + private fun TestScope.createKnockRequestsListPresenter( + canAccept: Boolean = true, + canDecline: Boolean = true, + canBan: Boolean = true, + knockRequestsFlow: Flow> = flowOf(emptyList()) + ): KnockRequestsListPresenter { + val room = FakeMatrixRoom( + canInviteResult = { Result.success(canAccept) }, + canKickResult = { Result.success(canDecline) }, + canBanResult = { Result.success(canBan) } + ) + val knockRequestsService = KnockRequestsService( + knockRequestsFlow = knockRequestsFlow, + coroutineScope = backgroundScope + ) + return KnockRequestsListPresenter( + room = room, + knockRequestsService = knockRequestsService, + ) + } +} diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListViewTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListViewTest.kt new file mode 100644 index 0000000000..5620c65ea7 --- /dev/null +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListViewTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +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.features.knockrequests.impl.R +import io.element.android.features.knockrequests.impl.data.aKnockRequest +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +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 kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class KnockRequestsListViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invoke the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setKnockRequestsListView( + aKnockRequestsListState( + eventSink = eventsRecorder, + ), + onBackClick = it + ) + rule.pressBack() + } + } + + @Test + fun `clicking on accept emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequest = aKnockRequest() + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(persistentListOf(knockRequest)), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_accept) + eventsRecorder.assertSingle(KnockRequestsListEvents.Accept(knockRequest)) + } + + @Test + fun `clicking on decline emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequest = aKnockRequest() + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(persistentListOf(knockRequest)), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_decline) + eventsRecorder.assertSingle(KnockRequestsListEvents.Decline(knockRequest)) + } + + @Test + fun `clicking on decline and ban emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequest = aKnockRequest() + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(persistentListOf(knockRequest)), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_knock_requests_list_decline_and_ban_action_title) + eventsRecorder.assertSingle(KnockRequestsListEvents.DeclineAndBan(knockRequest)) + } + + @Test + fun `clicking on accept all emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequests = persistentListOf(aKnockRequest(), aKnockRequest()) + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(knockRequests), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_knock_requests_list_accept_all_button_title) + eventsRecorder.assertSingle(KnockRequestsListEvents.AcceptAll) + } + + @Test + fun `retry on async view retry emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequests = persistentListOf(aKnockRequest(), aKnockRequest()) + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(knockRequests), + asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")), + actionTarget = KnockRequestsActionTarget.AcceptAll, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_retry) + eventsRecorder.assertSingle(KnockRequestsListEvents.RetryCurrentAction) + } + + @Test + fun `canceling async view emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequests = persistentListOf(aKnockRequest(), aKnockRequest()) + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(knockRequests), + asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")), + actionTarget = KnockRequestsActionTarget.AcceptAll, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(KnockRequestsListEvents.ResetCurrentAction) + } + + @Test + fun `confirming async view emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequests = persistentListOf(aKnockRequest(), aKnockRequest()) + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(knockRequests), + asyncAction = AsyncAction.ConfirmingNoParams, + actionTarget = KnockRequestsActionTarget.AcceptAll, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_knock_requests_list_accept_all_alert_confirm_button_title) + eventsRecorder.assertSingle(KnockRequestsListEvents.ConfirmCurrentAction) + } +} + +private fun AndroidComposeTestRule.setKnockRequestsListView( + state: KnockRequestsListState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + KnockRequestsListView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/knock/FakeKnockRequest.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/knock/FakeKnockRequest.kt index fad12e6bb6..88866b9dde 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/knock/FakeKnockRequest.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/knock/FakeKnockRequest.kt @@ -7,37 +7,42 @@ package io.element.android.libraries.matrix.test.room.knock +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.knock.KnockRequest import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask class FakeKnockRequest( + override val eventId: EventId = AN_EVENT_ID, override val userId: UserId = A_USER_ID, override val displayName: String? = A_USER_NAME, override val avatarUrl: String? = AN_AVATAR_URL, override val reason: String? = null, override val timestamp: Long? = null, + override val isSeen: Boolean = false, val acceptLambda: () -> Result = { lambdaError() }, val declineLambda: (String?) -> Result = { lambdaError() }, val declineAndBanLambda: (String?) -> Result = { lambdaError() }, val markAsSeenLambda: () -> Result = { lambdaError() }, ) : KnockRequest { - override suspend fun accept(): Result { - return acceptLambda() + override suspend fun accept(): Result = simulateLongTask{ + acceptLambda() } - override suspend fun decline(reason: String?): Result { - return declineLambda(reason) + override suspend fun decline(reason: String?): Result = simulateLongTask { + declineLambda(reason) } - override suspend fun declineAndBan(reason: String?): Result { - return declineAndBanLambda(reason) + override suspend fun declineAndBan(reason: String?): Result = simulateLongTask { + declineAndBanLambda(reason) } - override suspend fun markAsSeen(): Result { - return markAsSeenLambda() + override suspend fun markAsSeen(): Result = simulateLongTask { + markAsSeenLambda() } }