knock requests : add tests to the feature

This commit is contained in:
ganfra
2024-12-17 15:36:30 +01:00
parent 108253ca82
commit a6decdf697
8 changed files with 886 additions and 17 deletions

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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<List<KnockRequest>>,
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<Set<EventId>>(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<Unit>(IllegalArgumentException("Knock request not found"))
private fun MatrixRoom.wrappedKnockRequestsFlow() = knockRequestsFlow.map { knockRequests ->
private fun Flow<List<KnockRequest>>.wrapped() = map { knockRequests ->
knockRequests.map { KnockRequestWrapper(it) }
}
}

View File

@@ -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<KnockRequest>())
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<Unit>> { 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<Unit>> { 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<Unit>> { 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<List<KnockRequest>> = 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
)
}

View File

@@ -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<ComponentActivity>()
@Test
fun `clicking on view on single request invoke the expected callback`() {
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>(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<KnockRequestsBannerEvents>(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<KnockRequestsBannerEvents>()
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<KnockRequestsBannerEvents>()
rule.setKnockRequestsBannerView(
state = aKnockRequestsBannerState(
eventSink = eventsRecorder,
),
)
val close = rule.activity.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(KnockRequestsBannerEvents.Dismiss)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setKnockRequestsBannerView(
state: KnockRequestsBannerState,
onViewRequestsClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
KnockRequestsBannerView(
state = state,
onViewRequestsClick = onViewRequestsClick,
)
}
}

View File

@@ -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<Unit>> { 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<Unit>> { 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<String?, Result<Unit>> { 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<String?, Result<Unit>> { 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<Unit>> { 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<Unit>> { Result.success(Unit) }
val acceptFailureLambda = lambdaRecorder<Result<Unit>> { 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<List<KnockRequest>> = 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,
)
}
}

View File

@@ -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<ComponentActivity>()
@Test
fun `clicking on back invoke the expected callback`() {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>(expectEvents = false)
ensureCalledOnce {
rule.setKnockRequestsListView(
aKnockRequestsListState(
eventSink = eventsRecorder,
),
onBackClick = it
)
rule.pressBack()
}
}
@Test
fun `clicking on accept emit the expected event`() {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
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<KnockRequestsListEvents>()
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<KnockRequestsListEvents>()
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<KnockRequestsListEvents>()
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<KnockRequestsListEvents>()
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<KnockRequestsListEvents>()
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<KnockRequestsListEvents>()
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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setKnockRequestsListView(
state: KnockRequestsListState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
KnockRequestsListView(
state = state,
onBackClick = onBackClick,
)
}
}

View File

@@ -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<Unit> = { lambdaError() },
val declineLambda: (String?) -> Result<Unit> = { lambdaError() },
val declineAndBanLambda: (String?) -> Result<Unit> = { lambdaError() },
val markAsSeenLambda: () -> Result<Unit> = { lambdaError() },
) : KnockRequest {
override suspend fun accept(): Result<Unit> {
return acceptLambda()
override suspend fun accept(): Result<Unit> = simulateLongTask{
acceptLambda()
}
override suspend fun decline(reason: String?): Result<Unit> {
return declineLambda(reason)
override suspend fun decline(reason: String?): Result<Unit> = simulateLongTask {
declineLambda(reason)
}
override suspend fun declineAndBan(reason: String?): Result<Unit> {
return declineAndBanLambda(reason)
override suspend fun declineAndBan(reason: String?): Result<Unit> = simulateLongTask {
declineAndBanLambda(reason)
}
override suspend fun markAsSeen(): Result<Unit> {
return markAsSeenLambda()
override suspend fun markAsSeen(): Result<Unit> = simulateLongTask {
markAsSeenLambda()
}
}