quality: add bunch of tests for Security&Privacy new features

This commit is contained in:
ganfra
2026-01-07 20:12:41 +01:00
parent 92acf1edea
commit cbb91500f3
7 changed files with 752 additions and 21 deletions

View File

@@ -9,6 +9,7 @@
package io.element.android.features.securityandprivacy.impl
import android.os.Parcelable
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
@@ -71,7 +72,9 @@ class SecurityAndPrivacyFlowNode(
}
private val callback: SecurityAndPrivacyEntryPoint.Callback = callback()
private val navigator = BackstackSecurityAndPrivacyNavigator(callback, backstack)
@VisibleForTesting
val navigator = BackstackSecurityAndPrivacyNavigator(callback, backstack)
override fun onBuilt() {
super.onBuilt()

View File

@@ -20,14 +20,14 @@ open class ManageAuthorizedSpacesStateProvider : PreviewParameterProvider<Manage
get() = sequenceOf(
aManageAuthorizedSpacesState(),
aManageAuthorizedSpacesState(
authorizedSpacesData = anAuthorizedSpacesData(
authorizedSpacesSelection = anAuthorizedSpaceSelection(
unknownSpaceIds = listOf(aRoomId(99))
)
),
aManageAuthorizedSpacesState(
currentSelection = listOf(aRoomId(1), aRoomId(3)),
authorizedSpacesData = anAuthorizedSpacesData(
initialSelection = listOf(aRoomId(1)),
selectedIds = listOf(aRoomId(1), aRoomId(3)),
authorizedSpacesSelection = anAuthorizedSpaceSelection(
initialSelectedIds = listOf(aRoomId(1)),
),
),
)
@@ -49,23 +49,23 @@ private fun aSpaceRoomList(count: Int): List<SpaceRoom> {
}
}
fun anAuthorizedSpacesData(
fun anAuthorizedSpaceSelection(
joinedSpaces: List<SpaceRoom> = aSpaceRoomList(5),
unknownSpaceIds: List<RoomId> = emptyList(),
initialSelection: List<RoomId> = emptyList(),
initialSelectedIds: List<RoomId> = emptyList(),
) = AuthorizedSpacesSelection(
joinedSpaces = joinedSpaces.toImmutableList(),
unknownSpaceIds = unknownSpaceIds.toImmutableList(),
initialSelectedIds = initialSelection.toImmutableList(),
initialSelectedIds = initialSelectedIds.toImmutableList(),
)
private fun aManageAuthorizedSpacesState(
authorizedSpacesData: AuthorizedSpacesSelection = anAuthorizedSpacesData(),
currentSelection: List<RoomId> = emptyList(),
authorizedSpacesSelection: AuthorizedSpacesSelection = anAuthorizedSpaceSelection(),
selectedIds: List<RoomId> = emptyList(),
eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {},
) = ManageAuthorizedSpacesState(
selection = authorizedSpacesData,
selectedIds = currentSelection.toImmutableList(),
selection = authorizedSpacesSelection,
selectedIds = selectedIds.toImmutableList(),
isSelectionComplete = false,
eventSink = eventSink,
)

View File

@@ -0,0 +1,120 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.securityandprivacy.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bumble.appyx.core.modality.AncestryInfo
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.activeElement
import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
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 kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SecurityAndPrivacyFlowNodeTest {
@Test
fun `initial backstack contains SecurityAndPrivacy`() = runTest {
val flowNode = createFlowNode()
assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.SecurityAndPrivacy)
}
@Test
fun `openEditRoomAddress navigates to EditRoomAddress`() = runTest {
val flowNode = createFlowNode()
flowNode.navigator.openEditRoomAddress()
assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.EditRoomAddress)
}
@Test
fun `closeEditRoomAddress pops backstack`() = runTest {
val flowNode = createFlowNode()
flowNode.navigator.openEditRoomAddress()
assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.EditRoomAddress)
flowNode.navigator.closeEditRoomAddress()
assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.SecurityAndPrivacy)
}
@Test
fun `openManageAuthorizedSpaces navigates with forKnockRestricted false`() = runTest {
val flowNode = createFlowNode()
flowNode.navigator.openManageAuthorizedSpaces(forKnockRestricted = false)
assertThat(flowNode.currentNavTarget()).isEqualTo(
SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces(forKnockRestricted = false)
)
}
@Test
fun `openManageAuthorizedSpaces navigates with forKnockRestricted true`() = runTest {
val flowNode = createFlowNode()
flowNode.navigator.openManageAuthorizedSpaces(forKnockRestricted = true)
assertThat(flowNode.currentNavTarget()).isEqualTo(
SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces(forKnockRestricted = true)
)
}
@Test
fun `closeManageAuthorizedSpaces pops backstack`() = runTest {
val flowNode = createFlowNode()
flowNode.navigator.openManageAuthorizedSpaces(forKnockRestricted = false)
assertThat(flowNode.currentNavTarget())
.isInstanceOf(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces::class.java)
flowNode.navigator.closeManageAuthorizedSpaces()
assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.SecurityAndPrivacy)
}
@Test
fun `onDone invokes callback`() = runTest {
var onDoneCalled = false
val callback = object : SecurityAndPrivacyEntryPoint.Callback {
override fun onDone() {
onDoneCalled = true
}
}
val flowNode = createFlowNode(callback = callback)
flowNode.navigator.onDone()
assertThat(onDoneCalled).isTrue()
}
private fun createFlowNode(
callback: SecurityAndPrivacyEntryPoint.Callback = object : SecurityAndPrivacyEntryPoint.Callback {
override fun onDone() {}
},
): SecurityAndPrivacyFlowNode {
val buildContext = BuildContext(
ancestryInfo = AncestryInfo.Root,
savedStateMap = null,
customisations = NodeCustomisationDirectoryImpl()
)
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
initialRoomInfo = aRoomInfo(
joinRule = JoinRule.Invite,
historyVisibility = RoomHistoryVisibility.Shared
)
)
)
return SecurityAndPrivacyFlowNode(
buildContext = buildContext,
plugins = listOf(callback),
room = room,
)
}
private fun SecurityAndPrivacyFlowNode.currentNavTarget() = backstack.activeElement
}

View File

@@ -18,21 +18,29 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.AllowRule
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
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.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -50,7 +58,6 @@ class SecurityAndPrivacyPresenterTest {
assertThat(showRoomVisibilitySections).isFalse()
assertThat(showHistoryVisibilitySection).isFalse()
assertThat(showEncryptionSection).isFalse()
assertThat(isKnockEnabled).isFalse()
}
with(awaitItem()) {
assertThat(editedSettings).isEqualTo(savedSettings)
@@ -61,7 +68,6 @@ class SecurityAndPrivacyPresenterTest {
assertThat(showRoomVisibilitySections).isFalse()
assertThat(showHistoryVisibilitySection).isTrue()
assertThat(showEncryptionSection).isTrue()
assertThat(isKnockEnabled).isFalse()
}
}
}
@@ -364,17 +370,364 @@ class SecurityAndPrivacyPresenterTest {
}
@Test
fun `present - isKnockEnabled is true if the Knock feature flag is enabled`() = runTest {
fun `present - Restricted join rule maps to SpaceMember`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(
joinRule = JoinRule.Restricted(
rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))
),
historyVisibility = RoomHistoryVisibility.Shared,
)
)
)
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java)
val access = editedSettings.roomAccess as SecurityAndPrivacyRoomAccess.SpaceMember
assertThat(access.spaceIds).containsExactly(A_ROOM_ID)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SelectSpaceMemberAccess with single space auto-selects`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(
historyVisibility = RoomHistoryVisibility.Shared,
joinRule = JoinRule.Invite
)
)
)
val client = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
spaceService = FakeSpaceService(
joinedParentsResult = { _ ->
Result.success(listOf(aSpaceRoom(roomId = A_ROOM_ID)))
}
)
)
val presenter = createSecurityAndPrivacyPresenter(
room = room,
matrixClient = client,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.Knock.key to true,
FeatureFlags.SpaceSettings.key to true,
)
)
)
presenter.test {
assertThat(awaitItem().isKnockEnabled).isFalse()
assertThat(awaitItem().isKnockEnabled).isTrue()
skipItems(1)
val state = awaitItem()
assertThat(state.isSpaceMemberSelectable).isTrue()
state.eventSink(SecurityAndPrivacyEvent.SelectSpaceMemberAccess)
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java)
val access = editedSettings.roomAccess as SecurityAndPrivacyRoomAccess.SpaceMember
assertThat(access.spaceIds).containsExactly(A_ROOM_ID)
}
}
}
@Test
fun `present - SelectSpaceMemberAccess with multiple spaces opens ManageAuthorizedSpaces`() = runTest {
val openManageAuthorizedSpacesLambda = lambdaRecorder<Boolean, Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(openManageAuthorizedSpacesLambda = openManageAuthorizedSpacesLambda)
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(
historyVisibility = RoomHistoryVisibility.Shared,
joinRule = JoinRule.Invite
)
)
)
val client = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
spaceService = FakeSpaceService(
joinedParentsResult = { _ ->
Result.success(listOf(aSpaceRoom(roomId = A_ROOM_ID), aSpaceRoom(roomId = RoomId("!space2:matrix.org"))))
}
)
)
val presenter = createSecurityAndPrivacyPresenter(
room = room,
navigator = navigator,
matrixClient = client,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.SpaceSettings.key to true,
)
)
)
presenter.test {
skipItems(1)
val state = awaitItem()
assertThat(state.isSpaceMemberSelectable).isTrue()
state.eventSink(SecurityAndPrivacyEvent.SelectSpaceMemberAccess)
assert(openManageAuthorizedSpacesLambda).isCalledOnce().with(value(false))
}
}
@Test
fun `present - SpaceMember saves as Restricted join rule`() = runTest {
val updateJoinRuleLambda = lambdaRecorder<JoinRule, Result<Unit>> { Result.success(Unit) }
val updateRoomVisibilityLambda = lambdaRecorder<RoomVisibility, Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(
historyVisibility = RoomHistoryVisibility.Shared,
joinRule = JoinRule.Invite
)
),
updateJoinRuleResult = updateJoinRuleLambda,
updateRoomVisibilityResult = updateRoomVisibilityLambda,
)
val onDoneLambda = lambdaRecorder<Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(onDoneLambda = onDoneLambda)
val presenter = createSecurityAndPrivacyPresenter(room = room, navigator = navigator)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly)
val spaceMemberAccess = SecurityAndPrivacyRoomAccess.SpaceMember(
spaceIds = persistentListOf(A_ROOM_ID)
)
eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(spaceMemberAccess))
}
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java)
assertThat(canBeSaved).isTrue()
eventSink(SecurityAndPrivacyEvent.Save)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Loading)
}
room.givenRoomInfo(
aRoomInfo(
joinRule = JoinRule.Restricted(
rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))
),
historyVisibility = RoomHistoryVisibility.Shared,
)
)
skipItems(2)
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
}
assert(updateJoinRuleLambda).isCalledOnce().with(
value(JoinRule.Restricted(rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))))
)
onDoneLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - room visibility is NOT configurable for SpaceMember`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(
historyVisibility = RoomHistoryVisibility.Shared,
joinRule = JoinRule.Restricted(
rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))
)
)
)
)
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java)
assertThat(showRoomVisibilitySections).isFalse()
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - KnockRestricted join rule maps to AskToJoinWithSpaceMembers`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(
joinRule = JoinRule.KnockRestricted(
rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))
),
historyVisibility = RoomHistoryVisibility.Shared,
)
)
)
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember::class.java)
val access = editedSettings.roomAccess as SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember
assertThat(access.spaceIds).containsExactly(A_ROOM_ID)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - showAskToJoinWithSpaceMembersOption is true when both FFs enabled and spaces available`() = runTest {
val presenter = createSecurityAndPrivacyPresenter(
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.Knock.key to true,
FeatureFlags.SpaceSettings.key to true,
)
)
)
presenter.test {
skipItems(1)
// Without spaces available, AskToJoinWithSpaceMembers should not be selectable
with(awaitItem()) {
assertThat(isAskToJoinWithSpaceMembersSelectable).isFalse()
assertThat(showAskToJoinWithSpaceMemberOption).isFalse()
// AskToJoin should be shown instead
assertThat(showAskToJoinOption).isTrue()
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SelectAskToJoinWithSpaceMembersAccess with multiple spaces opens ManageAuthorizedSpaces`() = runTest {
val openManageAuthorizedSpacesLambda = lambdaRecorder<Boolean, Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(openManageAuthorizedSpacesLambda = openManageAuthorizedSpacesLambda)
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(
historyVisibility = RoomHistoryVisibility.Shared,
joinRule = JoinRule.Invite
)
)
)
val client = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
spaceService = FakeSpaceService(
joinedParentsResult = { _ ->
Result.success(listOf(aSpaceRoom(roomId = A_ROOM_ID), aSpaceRoom(roomId = RoomId("!space2:matrix.org"))))
}
)
)
val presenter = createSecurityAndPrivacyPresenter(
room = room,
navigator = navigator,
matrixClient = client,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.Knock.key to true,
FeatureFlags.SpaceSettings.key to true,
)
)
)
presenter.test {
skipItems(1)
// Wait for space selection mode to be set
val state = awaitItem()
assertThat(state.isAskToJoinWithSpaceMembersSelectable).isTrue()
state.eventSink(SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess)
assert(openManageAuthorizedSpacesLambda).isCalledOnce().with(value(true))
}
}
@Test
fun `present - AskToJoinWithSpaceMember saves as KnockRestricted join rule`() = runTest {
val updateJoinRuleLambda = lambdaRecorder<JoinRule, Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(
historyVisibility = RoomHistoryVisibility.Shared,
joinRule = JoinRule.Invite
)
),
updateJoinRuleResult = updateJoinRuleLambda,
)
val onDoneLambda = lambdaRecorder<Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(onDoneLambda = onDoneLambda)
val presenter = createSecurityAndPrivacyPresenter(room = room, navigator = navigator)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly)
val askToJoinAccess = SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember(
spaceIds = persistentListOf(A_ROOM_ID)
)
eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(askToJoinAccess))
}
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember::class.java)
assertThat(canBeSaved).isTrue()
eventSink(SecurityAndPrivacyEvent.Save)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Loading)
}
room.givenRoomInfo(
aRoomInfo(
joinRule = JoinRule.KnockRestricted(
rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))
),
historyVisibility = RoomHistoryVisibility.Shared,
)
)
// Saved settings are updated multiple times to match the edited settings
skipItems(2)
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
}
assert(updateJoinRuleLambda).isCalledOnce().with(
value(JoinRule.KnockRestricted(rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))))
)
onDoneLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - room visibility is configurable for AskToJoinWithSpaceMember`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(
historyVisibility = RoomHistoryVisibility.Shared,
joinRule = JoinRule.KnockRestricted(
rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))
)
)
)
)
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember::class.java)
assertThat(showRoomVisibilitySections).isTrue()
}
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(false))
eventSink(SecurityAndPrivacyEvent.ToggleRoomVisibility)
}
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true))
assertThat(canBeSaved).isTrue()
}
}
}
@@ -408,12 +761,17 @@ class SecurityAndPrivacyPresenterTest {
),
navigator: SecurityAndPrivacyNavigator = FakeSecurityAndPrivacyNavigator(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
matrixClient: MatrixClient = FakeMatrixClient(
userIdServerNameLambda = { serverName },
spaceService = FakeSpaceService(
joinedParentsResult = { Result.success(emptyList()) },
getSpaceRoomResult = { null }
),
),
): SecurityAndPrivacyPresenter {
return SecurityAndPrivacyPresenter(
room = room,
matrixClient = FakeMatrixClient(
userIdServerNameLambda = { serverName },
),
matrixClient = matrixClient,
navigator = navigator,
featureFlagService = featureFlagService,
)

View File

@@ -19,8 +19,11 @@ import io.element.android.features.securityandprivacy.impl.root.SecurityAndPriva
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyRoomAccess
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyState
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyView
import io.element.android.features.securityandprivacy.impl.root.SpaceSelectionMode
import io.element.android.features.securityandprivacy.impl.root.aSecurityAndPrivacySettings
import io.element.android.features.securityandprivacy.impl.root.aSecurityAndPrivacyState
import io.element.android.libraries.matrix.test.A_ROOM_ID
import kotlinx.collections.immutable.persistentListOf
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
@@ -179,6 +182,48 @@ class SecurityAndPrivacyViewTest {
rule.clickOn(R.string.screen_security_and_privacy_enable_encryption_alert_confirm_button_title)
recorder.assertSingle(SecurityAndPrivacyEvent.ConfirmEnableEncryption)
}
@Test
@Config(qualifiers = "h1024dp")
fun `click on space member access emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvent>()
val state = aSecurityAndPrivacyState(
eventSink = recorder,
spaceSelectionMode = SpaceSelectionMode.Single(A_ROOM_ID, null),
)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(R.string.screen_security_and_privacy_room_access_space_members_option_title)
recorder.assertSingle(SecurityAndPrivacyEvent.SelectSpaceMemberAccess)
}
@Test
@Config(qualifiers = "h1024dp")
fun `click on ask to join with space members emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvent>()
val state = aSecurityAndPrivacyState(
eventSink = recorder,
spaceSelectionMode = SpaceSelectionMode.Single(A_ROOM_ID, null),
)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(R.string.screen_security_and_privacy_ask_to_join_option_title)
recorder.assertSingle(SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess)
}
@Test
@Config(qualifiers = "h1024dp")
fun `manage spaces footer is shown when space member access is selected`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvent>(expectEvents = false)
val state = aSecurityAndPrivacyState(
eventSink = recorder,
spaceSelectionMode = SpaceSelectionMode.Multiple,
editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember(persistentListOf(A_ROOM_ID)),
),
)
rule.setSecurityAndPrivacyView(state)
// The footer text uses AnnotatedString with a link. Verify the footer text is displayed.
rule.onNodeWithText("Choose which spaces", substring = true).assertExists()
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSecurityAndPrivacyView(

View File

@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.securityandprivacy.impl.manageauthorizedspaces
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ManageAuthorizedSpacesPresenterTest {
@Test
fun `present - initial state has empty selection`() = runTest {
val presenter = ManageAuthorizedSpacesPresenter()
presenter.test {
with(awaitItem()) {
assertThat(selectedIds).isEmpty()
assertThat(isSelectionComplete).isFalse()
assertThat(isDoneButtonEnabled).isFalse()
}
}
}
@Test
fun `present - SetData event updates selection and initial selectedIds`() = runTest {
val presenter = ManageAuthorizedSpacesPresenter()
presenter.test {
val initialState = awaitItem()
val roomId = A_ROOM_ID
val data = AuthorizedSpacesSelection(
joinedSpaces = persistentListOf(),
unknownSpaceIds = persistentListOf(),
initialSelectedIds = persistentListOf(roomId)
)
initialState.eventSink(ManageAuthorizedSpacesEvent.SetData(data))
// SetData updates two state variables, which may emit intermediate states
skipItems(1)
with(awaitItem()) {
assertThat(selection).isEqualTo(data)
assertThat(selectedIds).containsExactly(roomId)
assertThat(isDoneButtonEnabled).isTrue()
}
}
}
@Test
fun `present - ToggleSpace event adds space to selectedIds`() = runTest {
val presenter = ManageAuthorizedSpacesPresenter()
presenter.test {
val initialState = awaitItem()
val roomId = A_ROOM_ID
initialState.eventSink(ManageAuthorizedSpacesEvent.ToggleSpace(roomId))
with(awaitItem()) {
assertThat(selectedIds).containsExactly(roomId)
assertThat(isDoneButtonEnabled).isTrue()
}
}
}
@Test
fun `present - ToggleSpace event removes space when already selected`() = runTest {
val presenter = ManageAuthorizedSpacesPresenter()
presenter.test {
val initialState = awaitItem()
val roomId = A_ROOM_ID
initialState.eventSink(ManageAuthorizedSpacesEvent.ToggleSpace(roomId))
val stateWithSelection = awaitItem()
assertThat(stateWithSelection.selectedIds).containsExactly(roomId)
stateWithSelection.eventSink(ManageAuthorizedSpacesEvent.ToggleSpace(roomId))
with(awaitItem()) {
assertThat(selectedIds).isEmpty()
assertThat(isDoneButtonEnabled).isFalse()
}
}
}
@Test
fun `present - Done event sets isSelectionComplete to true`() = runTest {
val presenter = ManageAuthorizedSpacesPresenter()
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ManageAuthorizedSpacesEvent.Done)
with(awaitItem()) {
assertThat(isSelectionComplete).isTrue()
}
}
}
}

View File

@@ -0,0 +1,109 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.securityandprivacy.impl.manageauthorizedspaces
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.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.previewutils.room.aSpaceRoom
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.toImmutableList
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ManageAuthorizedSpacesViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking back invokes callback`() {
ensureCalledOnce { callback ->
rule.setManageAuthorizedSpacesView(onBackClick = callback)
rule.pressBack()
}
}
@Test
fun `clicking space checkbox emits ToggleSpace event`() {
val roomId = A_ROOM_ID
val space = aSpaceRoom(roomId = roomId, displayName = "Test Space")
val recorder = EventsRecorder<ManageAuthorizedSpacesEvent>()
val state = aManageAuthorizedSpacesState(
selection = anAuthorizedSpaceSelection(
joinedSpaces = listOf(space)
),
eventSink = recorder
)
rule.setManageAuthorizedSpacesView(state)
rule.onNodeWithText("Test Space").performClick()
recorder.assertSingle(ManageAuthorizedSpacesEvent.ToggleSpace(roomId))
}
@Test
fun `clicking done button emits Done event`() {
val recorder = EventsRecorder<ManageAuthorizedSpacesEvent>()
val state = aManageAuthorizedSpacesState(
selectedIds = listOf(A_ROOM_ID),
eventSink = recorder
)
rule.setManageAuthorizedSpacesView(state)
rule.clickOn(CommonStrings.action_done)
recorder.assertSingle(ManageAuthorizedSpacesEvent.Done)
}
@Test
fun `done button is disabled when no spaces selected`() {
val recorder = EventsRecorder<ManageAuthorizedSpacesEvent>(expectEvents = false)
val state = aManageAuthorizedSpacesState(
selectedIds = emptyList(),
eventSink = recorder
)
rule.setManageAuthorizedSpacesView(state)
rule.clickOn(CommonStrings.action_done)
recorder.assertEmpty()
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setManageAuthorizedSpacesView(
state: ManageAuthorizedSpacesState = aManageAuthorizedSpacesState(
eventSink = EventsRecorder(expectEvents = false)
),
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
ManageAuthorizedSpacesView(
state = state,
onBackClick = onBackClick,
)
}
}
private fun aManageAuthorizedSpacesState(
selection: AuthorizedSpacesSelection = AuthorizedSpacesSelection(),
selectedIds: List<RoomId> = emptyList(),
isSelectionComplete: Boolean = false,
eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {},
) = ManageAuthorizedSpacesState(
selection = selection,
selectedIds = selectedIds.toImmutableList(),
isSelectionComplete = isSelectionComplete,
eventSink = eventSink,
)