From cbb91500f32d88952dbec76474e0cb4c117a2da2 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 7 Jan 2026 20:12:41 +0100 Subject: [PATCH] quality: add bunch of tests for Security&Privacy new features --- .../impl/SecurityAndPrivacyFlowNode.kt | 5 +- .../ManageAuthorizedSpacesStateProvider.kt | 22 +- .../impl/SecurityAndPrivacyFlowNodeTest.kt | 120 ++++++ .../impl/SecurityAndPrivacyPresenterTest.kt | 376 +++++++++++++++++- .../impl/SecurityAndPrivacyViewTest.kt | 45 +++ .../ManageAuthorizedSpacesPresenterTest.kt | 96 +++++ .../ManageAuthorizedSpacesViewTest.kt | 109 +++++ 7 files changed, 752 insertions(+), 21 deletions(-) create mode 100644 features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNodeTest.kt create mode 100644 features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesPresenterTest.kt create mode 100644 features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesViewTest.kt diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt index f7bdde7aa0..2c21964f48 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt @@ -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() diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesStateProvider.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesStateProvider.kt index e232d02022..2e062c186a 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesStateProvider.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesStateProvider.kt @@ -20,14 +20,14 @@ open class ManageAuthorizedSpacesStateProvider : PreviewParameterProvider { } } -fun anAuthorizedSpacesData( +fun anAuthorizedSpaceSelection( joinedSpaces: List = aSpaceRoomList(5), unknownSpaceIds: List = emptyList(), - initialSelection: List = emptyList(), + initialSelectedIds: List = emptyList(), ) = AuthorizedSpacesSelection( joinedSpaces = joinedSpaces.toImmutableList(), unknownSpaceIds = unknownSpaceIds.toImmutableList(), - initialSelectedIds = initialSelection.toImmutableList(), + initialSelectedIds = initialSelectedIds.toImmutableList(), ) private fun aManageAuthorizedSpacesState( - authorizedSpacesData: AuthorizedSpacesSelection = anAuthorizedSpacesData(), - currentSelection: List = emptyList(), + authorizedSpacesSelection: AuthorizedSpacesSelection = anAuthorizedSpaceSelection(), + selectedIds: List = emptyList(), eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {}, ) = ManageAuthorizedSpacesState( - selection = authorizedSpacesData, - selectedIds = currentSelection.toImmutableList(), + selection = authorizedSpacesSelection, + selectedIds = selectedIds.toImmutableList(), isSelectionComplete = false, eventSink = eventSink, ) diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNodeTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNodeTest.kt new file mode 100644 index 0000000000..cc32b59684 --- /dev/null +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNodeTest.kt @@ -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 +} diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyPresenterTest.kt index c035b5510d..9a166e4daf 100644 --- a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyPresenterTest.kt +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyPresenterTest.kt @@ -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 { } + 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> { Result.success(Unit) } + val updateRoomVisibilityLambda = lambdaRecorder> { 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 { } + 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 { } + 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> { 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 { } + 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, ) diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyViewTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyViewTest.kt index b15bc2fe37..10dac2213b 100644 --- a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyViewTest.kt +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyViewTest.kt @@ -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() + 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() + 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(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 AndroidComposeTestRule.setSecurityAndPrivacyView( diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesPresenterTest.kt new file mode 100644 index 0000000000..86a0b47fc5 --- /dev/null +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesPresenterTest.kt @@ -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() + } + } + } +} diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesViewTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesViewTest.kt new file mode 100644 index 0000000000..515fe590a3 --- /dev/null +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesViewTest.kt @@ -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() + + @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() + 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() + 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(expectEvents = false) + val state = aManageAuthorizedSpacesState( + selectedIds = emptyList(), + eventSink = recorder + ) + rule.setManageAuthorizedSpacesView(state) + rule.clickOn(CommonStrings.action_done) + recorder.assertEmpty() + } +} + +private fun AndroidComposeTestRule.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 = emptyList(), + isSelectionComplete: Boolean = false, + eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {}, +) = ManageAuthorizedSpacesState( + selection = selection, + selectedIds = selectedIds.toImmutableList(), + isSelectionComplete = isSelectionComplete, + eventSink = eventSink, +)