change (member moderation) : fix all existing tests

This commit is contained in:
ganfra 2025-05-19 22:16:17 +02:00
parent e405bf80a6
commit 0b6c5964a1
12 changed files with 64 additions and 726 deletions

View file

@ -35,6 +35,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@ -1182,6 +1183,9 @@ class MessagesPresenterTest {
textEditorState = aTextEditorStateMarkdown(initialText = "", initialFocus = false)
)
},
roomMemberModerationPresenter: Presenter<RoomMemberModerationState> = Presenter {
aRoomMemberModerationState()
},
encryptionService: FakeEncryptionService = FakeEncryptionService(),
actionListEventSink: (ActionListEvents) -> Unit = {},
): MessagesPresenter {
@ -1199,6 +1203,7 @@ class MessagesPresenterTest {
linkPresenter = { aLinkState() },
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
roomCallStatePresenter = { aStandByCallState() },
roomMemberModerationPresenter = roomMemberModerationPresenter,
syncService = FakeSyncService(),
snackbarDispatcher = SnackbarDispatcher(),
navigator = navigator,

View file

@ -53,6 +53,9 @@ import io.element.android.features.messages.impl.timeline.components.receipt.bot
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
@ -310,40 +313,42 @@ class MessagesViewTest {
@Test
@Config(qualifiers = "h1024dp")
fun `clicking on the avatar of the sender of an Event invoke expected callback`() {
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
fun `clicking on the avatar of the sender of an Event emits the expected event`() {
val eventsRecorder = EventsRecorder<MessagesEvents>()
val state = aMessagesState(
eventSink = eventsRecorder
)
val timelineItem = state.timelineState.timelineItems.first()
ensureCalledOnceWithParam(
param = (timelineItem as TimelineItem.Event).senderId
) { callback ->
rule.setMessagesView(
state = state,
onUserDataClick = callback,
val timelineEvent = state.timelineState.timelineItems.filterIsInstance<TimelineItem.Event>().first()
rule.setMessagesView(state = state)
rule.onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick()
eventsRecorder.assertSingle(
MessagesEvents.OnUserClicked(
MatrixUser(
userId = timelineEvent.senderId,
displayName = timelineEvent.senderProfile.getDisplayName(),
avatarUrl = timelineEvent.senderProfile.getAvatarUrl()
)
)
rule.onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick()
}
)
}
@Test
@Config(qualifiers = "h1024dp")
fun `clicking on the display name of the sender of an Event invoke expected callback`() {
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
val state = aMessagesState(
eventSink = eventsRecorder
)
val timelineItem = state.timelineState.timelineItems.first()
ensureCalledOnceWithParam(
param = (timelineItem as TimelineItem.Event).senderId
) { callback ->
rule.setMessagesView(
state = state,
onUserDataClick = callback,
fun `clicking on the display name of the sender of an Event emits expected event`() {
val eventsRecorder = EventsRecorder<MessagesEvents>()
val state = aMessagesState(eventSink = eventsRecorder)
val timelineEvent = state.timelineState.timelineItems.filterIsInstance<TimelineItem.Event>().first()
rule.setMessagesView(state = state)
rule.onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick()
eventsRecorder.assertSingle(
MessagesEvents.OnUserClicked(
MatrixUser(
userId = timelineEvent.senderId,
displayName = timelineEvent.senderProfile.getDisplayName(),
avatarUrl = timelineEvent.senderProfile.getAvatarUrl()
)
)
rule.onNodeWithTag(TestTags.timelineItemSenderName.value, useUnmergedTree = true).performClick()
}
)
}
@Test

View file

@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
@ -99,7 +100,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinne
state: PinnedMessagesListState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onUserDataClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
) {

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
@ -175,7 +176,7 @@ class TimelineViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView(
state: TimelineState,
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onUserDataClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onMessageLongClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),

View file

@ -29,7 +29,7 @@ import io.element.android.services.analytics.api.AnalyticsService
class RoomMemberListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: RoomMemberListPresenter.Factory,
private val presenter: RoomMemberListPresenter,
private val analyticsService: AnalyticsService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
) : Node(buildContext, plugins = plugins), RoomMemberListNavigator {
@ -39,7 +39,6 @@ class RoomMemberListNode @AssistedInject constructor(
}
private val callbacks = plugins<Callback>()
private val presenter = presenterFactory.create(this)
init {
lifecycle.subscribe(

View file

@ -16,8 +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.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.roommembermoderation.api.ModerationAction
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
@ -53,12 +51,7 @@ class RoomMemberListPresenter @AssistedInject constructor(
private val coroutineDispatchers: CoroutineDispatchers,
private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>,
private val encryptionService: EncryptionService,
@Assisted private val navigator: RoomMemberListNavigator,
) : Presenter<RoomMemberListState> {
@AssistedFactory
interface Factory {
fun create(navigator: RoomMemberListNavigator): RoomMemberListPresenter
}
@Composable
override fun present(): RoomMemberListState {
@ -168,10 +161,8 @@ class RoomMemberListPresenter @AssistedInject constructor(
is RoomMemberListEvents.RoomMemberSelected ->
if (event.roomMember.membership == RoomMembershipState.BAN) {
roomModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(ModerationAction.UnbanUser, event.roomMember.toMatrixUser()))
} else if (!isDm.value && (roomModerationState.canBan || roomModerationState.canKick)) {
roomModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.roomMember.toMatrixUser()))
} else {
navigator.openRoomMemberDetails(event.roomMember.userId)
roomModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.roomMember.toMatrixUser()))
}
}
}

View file

@ -11,9 +11,8 @@ 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.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
@ -24,7 +23,6 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
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.aRoomInfo
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -42,12 +40,12 @@ class RoomMemberListPresenterTest {
fun `member loading is done automatically on start, but is async`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = { Result.success(Unit) },
canInviteResult = { Result.success(true) }
).apply {
// Needed to avoid discarding the loaded members as a partial and invalid result
givenRoomInfo(aRoomInfo(joinedMembersCount = 2))
}
updateMembersResult = { Result.success(Unit) },
canInviteResult = { Result.success(true) }
).apply {
// Needed to avoid discarding the loaded members as a partial and invalid result
givenRoomInfo(aRoomInfo(joinedMembersCount = 2))
}
)
val presenter = createPresenter(joinedRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
@ -97,9 +95,9 @@ class RoomMemberListPresenterTest {
val presenter = createPresenter(
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = { Result.success(Unit) },
canInviteResult = { Result.success(true) }
)
updateMembersResult = { Result.success(Unit) },
canInviteResult = { Result.success(true) }
)
)
)
moleculeFlow(RecompositionMode.Immediate) {
@ -204,12 +202,12 @@ class RoomMemberListPresenterTest {
}
@Test
fun `present - RoomMemberSelected by default opens the room member details through the navigator`() = runTest {
val navigator = FakeRoomMemberListNavigator()
val roomMembersModerationStateLambda = { aRoomMembersModerationState(canDisplayModerationActions = false) }
fun `present - RoomMemberSelected will open the moderation options when target user is not banned`() = runTest {
val roomMemberModerationPresenter= Presenter {
aRoomMemberModerationState(canBan = true, canKick = true)
}
val presenter = createPresenter(
roomMembersModerationStateLambda = roomMembersModerationStateLambda,
navigator = navigator,
roomMemberModerationPresenter = roomMemberModerationPresenter,
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = { Result.success(Unit) },
@ -222,36 +220,6 @@ class RoomMemberListPresenterTest {
}.test {
skipItems(1)
awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(aVictor()))
assertThat(navigator.openRoomMemberDetailsCallCount).isEqualTo(1)
}
}
@Test
fun `present - RoomMemberSelected will open the moderation options if the current user can use them`() = runTest {
val navigator = FakeRoomMemberListNavigator()
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMembersModerationStateLambda = {
aRoomMembersModerationState(
canDisplayModerationActions = true,
eventSink = eventsRecorder,
)
}
val presenter = createPresenter(
roomMembersModerationStateLambda = roomMembersModerationStateLambda,
navigator = navigator,
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = { Result.success(Unit) },
canInviteResult = { Result.success(true) }
)
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(aVictor()))
eventsRecorder.assertSingle(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
}
}
}
@ -277,19 +245,19 @@ private fun TestScope.createDataSource(
private fun TestScope.createPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
joinedRoom: JoinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = { Result.success(Unit) }
)
),
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers),
roomMembersModerationStateLambda: () -> RoomMembersModerationState = { aRoomMembersModerationState() },
encryptedService: FakeEncryptionService = FakeEncryptionService(),
navigator: RoomMemberListNavigator = object : RoomMemberListNavigator {}
roomMemberModerationPresenter: Presenter<RoomMemberModerationState> = Presenter {
aRoomMemberModerationState()
},
) = RoomMemberListPresenter(
room = joinedRoom,
roomMemberListDataSource = roomMemberListDataSource,
coroutineDispatchers = coroutineDispatchers,
roomMembersModerationPresenter = { roomMembersModerationStateLambda() },
roomMembersModerationPresenter = roomMemberModerationPresenter,
encryptionService = encryptedService,
navigator = navigator,
)

View file

@ -1,351 +0,0 @@
/*
* Copyright 2024 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.roomdetails.impl.members.moderation
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.roomdetails.impl.aJoinedRoom
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aVictor
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.UserId
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.room.RoomMembershipState
import io.element.android.libraries.matrix.test.A_REASON
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.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomMembersModerationPresenterTest {
@Test
fun `canDisplayModerationActions - when room is DM is false`() = runTest {
val room = aJoinedRoom(
isPublic = true,
activeMemberCount = 2,
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
).apply {
givenRoomInfo(aRoomInfo(isDirect = true, activeMembersCount = 2))
}
val presenter = createRoomMembersModerationPresenter(joinedRoom = room)
presenter.test {
assertThat(awaitItem().canDisplayModerationActions).isFalse()
}
}
@Test
fun `canDisplayModerationActions - when user can kick other users, FF is enabled and room is not a DM returns true`() = runTest {
val room = aJoinedRoom(
activeMemberCount = 10,
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
)
val presenter = createRoomMembersModerationPresenter(joinedRoom = room)
presenter.test {
skipItems(1)
assertThat(awaitItem().canDisplayModerationActions).isTrue()
}
}
@Test
fun `canDisplayModerationActions - when user can ban other users, FF is enabled and room is not a DM returns true`() = runTest {
val room = aJoinedRoom(
activeMemberCount = 10,
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
)
val presenter = createRoomMembersModerationPresenter(joinedRoom = room)
presenter.test {
skipItems(1)
assertThat(awaitItem().canDisplayModerationActions).isTrue()
}
}
@Test
fun `present - SelectRoomMember when the current user has permissions displays member actions`() = runTest {
val room = aJoinedRoom(
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
)
val selectedMember = aVictor()
val presenter = createRoomMembersModerationPresenter(joinedRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
with(awaitItem()) {
assertThat(this.selectedRoomMember).isNotNull()
assertThat(this.selectedRoomMember?.userId).isEqualTo(selectedMember.userId)
assertThat(actions).containsExactly(
ModerationAction.DisplayProfile(selectedMember.userId),
ModerationAction.KickUser(selectedMember.userId),
ModerationAction.BanUser(selectedMember.userId)
)
}
}
}
@Test
fun `present - SelectRoomMember displays only view profile if selected member has same power level as the current user`() = runTest {
val room = aJoinedRoom(
sessionId = A_USER_ID,
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
)
val selectedMember = aRoomMember(A_USER_ID_2, powerLevel = 100L)
val presenter = createRoomMembersModerationPresenter(joinedRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
with(awaitItem()) {
assertThat(this.selectedRoomMember).isNotNull()
assertThat(this.selectedRoomMember?.userId).isEqualTo(selectedMember.userId)
assertThat(actions).containsExactly(
ModerationAction.DisplayProfile(selectedMember.userId),
)
}
}
}
@Test
fun `present - SelectRoomMember displays an unban confirmation dialog when the member is banned`() = runTest {
val selectedMember = aRoomMember(A_USER_ID_2, membership = RoomMembershipState.BAN)
val room = aJoinedRoom(
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
)
val presenter = createRoomMembersModerationPresenter(joinedRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
with(awaitItem()) {
assertThat(selectedRoomMember).isNull()
assertThat(unbanUserAsyncAction).isEqualTo(ConfirmingRoomMemberAction(selectedMember))
}
}
}
@Test
fun `present - Kick requires confirmation and then kicks the user`() = runTest {
val analyticsService = FakeAnalyticsService()
val kickUserResult = lambdaRecorder<UserId, String?, Result<Unit>> { _, _ -> Result.success(Unit) }
val room = aJoinedRoom(
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
kickUserResult = kickUserResult,
)
val selectedMember = aVictor()
val presenter = createRoomMembersModerationPresenter(joinedRoom = room, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
val confirmingState = awaitItem()
assertThat(confirmingState.kickUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams)
// Confirm
confirmingState.eventSink(RoomMembersModerationEvents.DoKickUser(reason = A_REASON))
skipItems(1)
val loadingState = awaitItem()
assertThat(loadingState.actions).isEmpty()
assertThat(loadingState.kickUserAsyncAction).isEqualTo(AsyncAction.Loading)
with(awaitItem()) {
assertThat(kickUserAsyncAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(selectedRoomMember).isNull()
}
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.KickMember))
kickUserResult.assertions().isCalledOnce().with(
value(selectedMember.userId),
value(A_REASON),
)
}
}
@Test
fun `present - BanUser requires confirmation and then bans the user`() = runTest {
val analyticsService = FakeAnalyticsService()
val banUserResult = lambdaRecorder<UserId, String?, Result<Unit>> { _, _ -> Result.success(Unit) }
val room = aJoinedRoom(
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
banUserResult = banUserResult,
)
val selectedMember = aVictor()
val presenter = createRoomMembersModerationPresenter(joinedRoom = room, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
val confirmingState = awaitItem()
assertThat(confirmingState.banUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams)
// Confirm
confirmingState.eventSink(RoomMembersModerationEvents.DoBanUser(reason = A_REASON))
skipItems(1)
val loadingItem = awaitItem()
assertThat(loadingItem.actions).isEmpty()
assertThat(loadingItem.selectedRoomMember).isNull()
assertThat(loadingItem.banUserAsyncAction).isEqualTo(AsyncAction.Loading)
with(awaitItem()) {
assertThat(banUserAsyncAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(selectedRoomMember).isNull()
}
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.BanMember))
banUserResult.assertions().isCalledOnce().with(
value(selectedMember.userId),
value(A_REASON),
)
}
}
@Test
fun `present - UnbanUser requires confirmation and then unbans the user`() = runTest {
val analyticsService = FakeAnalyticsService()
val selectedMember = aRoomMember(A_USER_ID_2, membership = RoomMembershipState.BAN)
val room = aJoinedRoom(
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
unBanUserResult = { _, _ -> Result.success(Unit) },
).apply {
givenRoomMembersState(RoomMembersState.Ready(persistentListOf(selectedMember)))
}
val presenter = createRoomMembersModerationPresenter(joinedRoom = room, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
// Displays unban confirmation dialog
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
val confirmingState = awaitItem()
assertThat(confirmingState.selectedRoomMember).isNull()
assertThat(confirmingState.actions).isEmpty()
assertThat(confirmingState.unbanUserAsyncAction).isEqualTo(ConfirmingRoomMemberAction(selectedMember))
// Confirms unban
confirmingState.eventSink(RoomMembersModerationEvents.UnbanUser(selectedMember.userId))
assertThat(awaitItem().unbanUserAsyncAction).isEqualTo(AsyncAction.Loading)
with(awaitItem()) {
assertThat(unbanUserAsyncAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(selectedRoomMember).isNull()
}
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.UnbanMember))
}
}
@Test
fun `present - Reset removes the selected user and actions`() = runTest {
val room = aJoinedRoom(
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.USER) },
)
val presenter = createRoomMembersModerationPresenter(joinedRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
// Select a user
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
// Reset state
awaitItem().eventSink(RoomMembersModerationEvents.Reset)
val finalItem = awaitItem()
assertThat(finalItem.selectedRoomMember).isNull()
assertThat(finalItem.actions).isEmpty()
}
}
@Test
fun `present - Reset resets any async actions`() = runTest {
val room = aJoinedRoom(
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
kickUserResult = { _, _ -> Result.failure(Throwable("Eek")) },
banUserResult = { _, _ -> Result.failure(Throwable("Eek")) },
unBanUserResult = { _, _ -> Result.failure(Throwable("Eek")) },
userRoleResult = { Result.success(RoomMember.Role.USER) },
)
val presenter = createRoomMembersModerationPresenter(joinedRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialItem = awaitItem()
// Kick user and fail
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.DoKickUser(reason = ""))
skipItems(1)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
// Reset it
initialItem.eventSink(RoomMembersModerationEvents.Reset)
assertThat(awaitItem().kickUserAsyncAction).isEqualTo(AsyncAction.Uninitialized)
// Ban user and fail
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.DoBanUser(reason = ""))
skipItems(1)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
// Reset it
initialItem.eventSink(RoomMembersModerationEvents.Reset)
assertThat(awaitItem().banUserAsyncAction).isEqualTo(AsyncAction.Uninitialized)
// Unban user and fail
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor().copy(membership = RoomMembershipState.BAN)))
val confirmingState = awaitItem()
assertThat(confirmingState.unbanUserAsyncAction).isInstanceOf(AsyncAction.Confirming::class.java)
confirmingState.eventSink(RoomMembersModerationEvents.UnbanUser(aVictor().userId))
assertThat(awaitItem().unbanUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().unbanUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
// Reset it
initialItem.eventSink(RoomMembersModerationEvents.Reset)
assertThat(awaitItem().unbanUserAsyncAction).isEqualTo(AsyncAction.Uninitialized)
}
}
private fun TestScope.createRoomMembersModerationPresenter(
joinedRoom: FakeJoinedRoom = aJoinedRoom(),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): RoomMembersModerationPresenter {
return RoomMembersModerationPresenter(
room = joinedRoom,
dispatchers = dispatchers,
analyticsService = analyticsService,
)
}
}

View file

@ -1,274 +0,0 @@
/*
* Copyright 2024 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.roomdetails.impl.members.moderation
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.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.anAlice
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_REASON
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBackKey
import org.junit.Ignore
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 RoomMembersModerationViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Ignore("This test is not passing yet, need to investigate")
@Test
fun `clicking on back emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
actions = listOf(
ModerationAction.DisplayProfile(roomMember.userId),
),
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
rule.pressBackKey()
// Give time for the bottom sheet to animate
rule.mainClock.advanceTimeBy(1_000)
eventsRecorder.assertSingle(RoomMembersModerationEvents.Reset)
}
@Test
fun `clicking on 'See user info' invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>(expectEvents = false)
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
actions = listOf(
ModerationAction.DisplayProfile(roomMember.userId),
),
eventSink = eventsRecorder
)
ensureCalledOnceWithParam(roomMember.userId) { callback ->
rule.setRoomMembersModerationView(
state = state,
onDisplayMemberProfile = callback
)
rule.clickOn(CommonStrings.screen_bottom_sheet_manage_room_member_member_user_info)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on 'Remove member' emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
actions = listOf(
ModerationAction.DisplayProfile(roomMember.userId),
ModerationAction.KickUser(roomMember.userId),
),
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
rule.clickOn(CommonStrings.screen_bottom_sheet_manage_room_member_remove)
// Give time for the bottom sheet to animate
rule.mainClock.advanceTimeBy(1_000)
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser)
}
@Test
fun `cancelling 'Remove member' confirmation emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
// Note: the string key semantics is not perfect here :/
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(RoomMembersModerationEvents.Reset)
}
@Test
fun `confirming 'Remove member' reason edition then validation emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
val reason = rule.activity.getString(CommonStrings.common_reason)
rule.onNodeWithText(reason).performTextInput(A_REASON)
rule.clickOn(CommonStrings.screen_bottom_sheet_manage_room_member_kick_member_confirmation_action)
eventsRecorder.assertSingle(RoomMembersModerationEvents.DoKickUser(reason = A_REASON))
}
@Test
fun `confirming 'Remove member' confirmation emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
// Note: the string key semantics is not perfect here :/
rule.clickOn(CommonStrings.screen_bottom_sheet_manage_room_member_kick_member_confirmation_action)
eventsRecorder.assertSingle(RoomMembersModerationEvents.DoKickUser(reason = ""))
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on 'Remove and ban member' emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
actions = listOf(
ModerationAction.DisplayProfile(roomMember.userId),
ModerationAction.KickUser(roomMember.userId),
ModerationAction.BanUser(roomMember.userId),
),
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
// Note: the string key semantics is not perfect here :/
rule.clickOn(R.string.screen_room_member_list_manage_member_remove_confirmation_ban)
// Give time for the bottom sheet to animate
rule.mainClock.advanceTimeBy(1_000)
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser)
}
@Test
fun `cancelling 'Remove and ban member' confirmation emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
// Note: the string key semantics is not perfect here :/
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(RoomMembersModerationEvents.Reset)
}
@Test
fun `confirming 'Remove and ban member' reason edition emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
val reason = rule.activity.getString(CommonStrings.common_reason)
rule.onNodeWithText(reason).performTextInput(A_REASON)
rule.clickOn(CommonStrings.screen_bottom_sheet_manage_room_member_ban_member_confirmation_action)
eventsRecorder.assertSingle(RoomMembersModerationEvents.DoBanUser(reason = A_REASON))
}
@Test
fun `confirming 'Remove and ban member' confirmation emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
// Note: the string key semantics is not perfect here :/
rule.clickOn(CommonStrings.screen_bottom_sheet_manage_room_member_ban_member_confirmation_action)
eventsRecorder.assertSingle(RoomMembersModerationEvents.DoBanUser(reason = ""))
}
@Test
fun `cancelling 'Unban member' confirmation emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
unbanUserAsyncAction = ConfirmingRoomMemberAction(roomMember),
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
// Note: the string key semantics is not perfect here :/
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(RoomMembersModerationEvents.Reset)
}
@Test
fun `confirming 'Unban member' confirmation emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
unbanUserAsyncAction = ConfirmingRoomMemberAction(roomMember),
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
// Note: the string key semantics is not perfect here :/
rule.clickOn(R.string.screen_room_member_list_manage_member_unban_action)
eventsRecorder.assertSingle(RoomMembersModerationEvents.UnbanUser(roomMember.userId))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomMembersModerationView(
state: RoomMembersModerationState,
onDisplayMemberProfile: (UserId) -> Unit = EnsureNeverCalledWithParam()
) {
setContent {
RoomMembersModerationView(
state = state,
onDisplayMemberProfile = onDisplayMemberProfile,
)
}
}

View file

@ -7,13 +7,10 @@
package io.element.android.features.roommembermoderation.impl
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.architecture.AsyncData
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList

View file

@ -12,15 +12,11 @@ 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.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.toPersistentList
class RoomMemberModerationStateProvider : PreviewParameterProvider<InternalRoomMemberModerationState> {
class InternalRoomMemberModerationStateProvider : PreviewParameterProvider<InternalRoomMemberModerationState> {
override val values: Sequence<InternalRoomMemberModerationState>
get() = sequenceOf(
aRoomMembersModerationState(

View file

@ -317,7 +317,7 @@ private fun RoomMemberActionsBottomSheet(
@PreviewsDayNight
@Composable
internal fun RoomMembersModerationViewPreview(@PreviewParameter(RoomMemberModerationStateProvider::class) state: InternalRoomMemberModerationState) {
internal fun RoomMemberModerationViewPreview(@PreviewParameter(InternalRoomMemberModerationStateProvider::class) state: InternalRoomMemberModerationState) {
ElementPreview {
Box(
modifier = Modifier