From 2ac333bb52b58802b0407dc7e5e8f9a2381def63 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 21 May 2025 18:31:08 +0200 Subject: [PATCH] change (member moderation) : clean and add tests on Presenter --- .../impl/members/RoomMemberListPresenter.kt | 4 +- .../impl/build.gradle.kts | 3 + .../impl/RoomMemberModerationPresenter.kt | 22 +- .../impl/RoomMemberModerationPresenterTest.kt | 338 ++++++++++++++++++ 4 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index 4de57787c8..c6872bf9e0 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import dagger.assisted.AssistedInject import io.element.android.features.roommembermoderation.api.ModerationAction import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents import io.element.android.features.roommembermoderation.api.RoomMemberModerationState @@ -44,8 +43,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext +import javax.inject.Inject -class RoomMemberListPresenter @AssistedInject constructor( +class RoomMemberListPresenter @Inject constructor( private val room: JoinedRoom, private val roomMemberListDataSource: RoomMemberListDataSource, private val coroutineDispatchers: CoroutineDispatchers, diff --git a/features/roommembermoderation/impl/build.gradle.kts b/features/roommembermoderation/impl/build.gradle.kts index 3d566077eb..f70c7e968e 100644 --- a/features/roommembermoderation/impl/build.gradle.kts +++ b/features/roommembermoderation/impl/build.gradle.kts @@ -36,4 +36,7 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.tests.testutils) testImplementation(projects.services.analytics.test) + testImplementation(libs.test.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit) + } diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt index 0359a3120e..2d8668bd81 100644 --- a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt @@ -71,21 +71,19 @@ class RoomMemberModerationPresenter @Inject constructor( fun handleEvent(event: RoomMemberModerationEvents) { when (event) { is RoomMemberModerationEvents.ShowActionsForUser -> { - coroutineScope.launch { - selectedUser = event.user - val member = room.membersStateFlow.value.roomMembers()?.firstOrNull { - it.userId == event.user.userId - } - moderationActions.value = computeModerationActions( - member = member, - canKick = canKick.value, - canBan = canBan.value, - currentUserMemberPowerLevel = currentUserMemberPowerLevel.value, - ) + selectedUser = event.user + val member = room.membersStateFlow.value.roomMembers()?.firstOrNull { + it.userId == event.user.userId } + moderationActions.value = computeModerationActions( + member = member, + canKick = canKick.value, + canBan = canBan.value, + currentUserMemberPowerLevel = currentUserMemberPowerLevel.value, + ) } is RoomMemberModerationEvents.ProcessAction -> { - when (val action = event.action) { + when (event.action) { is ModerationAction.DisplayProfile -> Unit is ModerationAction.KickUser -> { selectedUser = event.targetUser diff --git a/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt new file mode 100644 index 0000000000..5a38b76120 --- /dev/null +++ b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt @@ -0,0 +1,338 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roommembermoderation.impl + +import app.cash.turbine.TurbineTestContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.roommembermoderation.api.ModerationAction +import io.element.android.features.roommembermoderation.api.ModerationActionState +import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class RoomMemberModerationPresenterTest { + + @get:Rule + val warmUpRule = WarmUpRule() + + private val targetUser = MatrixUser(userId = A_USER_ID) + + @Test + fun `present - initial state`() = runTest { + val room = aJoinedRoom() + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + assertThat(initialState.canKick).isFalse() + assertThat(initialState.canBan).isFalse() + assertThat(initialState.selectedUser).isNull() + assertThat(initialState.banUserAsyncAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.kickUserAsyncAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.unbanUserAsyncAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.actions).isEmpty() + } + } + + @Test + fun `present - show actions when canBan=false, canKick=false`() = runTest { + val room = aJoinedRoom( + canBan = false, + canKick = false, + myUserRole = RoomMember.Role.USER, + targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.USER.powerLevel) + ) + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(targetUser)) + skipItems(1) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.actions).containsExactly( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ) + } + } + + @Test + fun `present - show actions when canBan=true, canKick=true, userRole=Admin and target member is unknown`() = runTest { + val room = aJoinedRoom( + canBan = true, + canKick = true, + myUserRole = RoomMember.Role.ADMIN, + targetRoomMember = null + ) + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(targetUser)) + skipItems(2) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.actions).containsExactly( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ModerationActionState(action = ModerationAction.KickUser, isEnabled = true), + ModerationActionState(action = ModerationAction.BanUser, isEnabled = true), + ) + } + } + + @Test + fun `show actions when canBan=true, canKick=true, userRole=Admin and target is User`() = runTest { + val room = aJoinedRoom( + canBan = true, + canKick = true, + myUserRole = RoomMember.Role.ADMIN, + targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.USER.powerLevel) + ) + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(targetUser)) + skipItems(2) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.actions).containsExactly( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ModerationActionState(action = ModerationAction.KickUser, isEnabled = true), + ModerationActionState(action = ModerationAction.BanUser, isEnabled = true), + ) + } + } + + @Test + fun `show actions when canBan=true, canKick=true, userRole=Moderator and target is Admin`() = runTest { + val room = aJoinedRoom( + canBan = true, + canKick = true, + myUserRole = RoomMember.Role.MODERATOR, + targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.ADMIN.powerLevel) + ) + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(targetUser)) + skipItems(2) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.actions).containsExactly( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ModerationActionState(action = ModerationAction.KickUser, isEnabled = false), + ModerationActionState(action = ModerationAction.BanUser, isEnabled = false), + ) + } + } + + @Test + fun `present - process kick action sets confirming state`() = runTest { + createRoomMemberModerationPresenter(room = aJoinedRoom()).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.KickUser + ) + ) + skipItems(1) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.kickUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams) + } + } + + @Test + fun `present - process ban action sets confirming state`() = runTest { + createRoomMemberModerationPresenter(room = aJoinedRoom()).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.BanUser + ) + ) + skipItems(1) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.banUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams) + } + } + + @Test + fun `present - process unban action sets confirming state`() = runTest { + createRoomMemberModerationPresenter(room = aJoinedRoom()).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.UnbanUser + ) + ) + skipItems(1) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.unbanUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams) + } + } + + @Test + fun `present - do kick user with success`() = runTest { + createRoomMemberModerationPresenter(room = aJoinedRoom()).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.KickUser + ) + ) + skipItems(2) + initialState.eventSink(InternalRoomMemberModerationEvents.DoKickUser("Reason")) + skipItems(1) + val loadingState = awaitState() + assertThat(loadingState.kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java) + val successState = awaitState() + assertThat(successState.kickUserAsyncAction).isInstanceOf(AsyncAction.Success::class.java) + assertThat(successState.selectedUser).isNull() + } + } + + @Test + fun `present - do ban user with success`() = runTest { + createRoomMemberModerationPresenter(room = aJoinedRoom()).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.BanUser + ) + ) + skipItems(2) + initialState.eventSink(InternalRoomMemberModerationEvents.DoBanUser("Reason")) + skipItems(1) + val loadingState = awaitState() + assertThat(loadingState.banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java) + val successState = awaitState() + assertThat(successState.banUserAsyncAction).isInstanceOf(AsyncAction.Success::class.java) + assertThat(successState.selectedUser).isNull() + } + } + + @Test + fun `present - do unban user with success`() = runTest { + createRoomMemberModerationPresenter(room = aJoinedRoom()).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.UnbanUser + ) + ) + skipItems(2) + initialState.eventSink(InternalRoomMemberModerationEvents.DoUnbanUser) + skipItems(1) + val loadingState = awaitState() + assertThat(loadingState.unbanUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java) + val successState = awaitState() + assertThat(successState.unbanUserAsyncAction).isInstanceOf(AsyncAction.Success::class.java) + assertThat(successState.selectedUser).isNull() + } + } + + @Test + fun `present - do kick user with failure`() = runTest { + val error = RuntimeException("Test error") + val room = aJoinedRoom( + kickUserResult = Result.failure(error), + ) + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.KickUser + ) + ) + skipItems(2) + initialState.eventSink(InternalRoomMemberModerationEvents.DoKickUser("Reason")) + skipItems(1) + val loadingState = awaitState() + assertThat(loadingState.kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java) + val failureState = awaitState() + assertThat(failureState.kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java) + } + } + + @Test + fun `present - reset clears all async actions and selected user`() = runTest { + createRoomMemberModerationPresenter(room = aJoinedRoom()).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction(targetUser = targetUser, action = ModerationAction.BanUser) + ) + skipItems(2) + initialState.eventSink(InternalRoomMemberModerationEvents.Reset) + skipItems(1) + val resetState = awaitState() + assertThat(resetState.selectedUser).isNull() + assertThat(resetState.banUserAsyncAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + private fun aJoinedRoom( + canKick: Boolean = false, + canBan: Boolean = false, + myUserRole: RoomMember.Role = RoomMember.Role.USER, + kickUserResult: Result = Result.success(Unit), + banUserResult: Result = Result.success(Unit), + unBanUserResult: Result = Result.success(Unit), + targetRoomMember: RoomMember? = null, + ): JoinedRoom { + return FakeJoinedRoom( + kickUserResult = { _, _ -> kickUserResult }, + banUserResult = { _, _ -> banUserResult }, + unBanUserResult = { _, _ -> unBanUserResult }, + baseRoom = FakeBaseRoom( + canBanResult = { _ -> Result.success(canBan) }, + canKickResult = { _ -> Result.success(canKick) }, + userRoleResult = { Result.success(myUserRole) }, + ), + ).apply { + val roomMembers = listOfNotNull(targetRoomMember).toPersistentList() + givenRoomMembersState(state = RoomMembersState.Ready(roomMembers)) + } + } + + private fun TestScope.createRoomMemberModerationPresenter( + room: JoinedRoom, + dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + analyticsService: AnalyticsService = FakeAnalyticsService(), + ): RoomMemberModerationPresenter { + return RoomMemberModerationPresenter( + room = room, + dispatchers = dispatchers, + analyticsService = analyticsService, + ) + } + + private suspend fun TurbineTestContext.awaitState(): InternalRoomMemberModerationState { + return awaitItem() as InternalRoomMemberModerationState + } +}