knock requests : add tests to the feature
This commit is contained in:
@@ -14,6 +14,11 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.knockrequests.impl"
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupAnvil()
|
||||
@@ -31,7 +36,12 @@ dependencies {
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.knockrequests.impl.data
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
|
||||
@Module
|
||||
@ContributesTo(RoomScope::class)
|
||||
object KnockRequestsModule {
|
||||
@Provides
|
||||
@SingleIn(RoomScope::class)
|
||||
fun knockRequestsService(room: MatrixRoom): KnockRequestsService {
|
||||
return KnockRequestsService(room.knockRequestsFlow, room.roomCoroutineScope)
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,13 @@
|
||||
package io.element.android.features.knockrequests.impl.data
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -22,23 +22,24 @@ import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
class KnockRequestsService @Inject constructor(room: MatrixRoom) {
|
||||
class KnockRequestsService(
|
||||
knockRequestsFlow: Flow<List<KnockRequest>>,
|
||||
coroutineScope: CoroutineScope,
|
||||
) {
|
||||
|
||||
// Keep track of the knock requests that have been handled, so we don't have to wait for sync to remove them.
|
||||
private val handledKnockRequestIds = MutableStateFlow<Set<EventId>>(emptySet())
|
||||
|
||||
val knockRequestsFlow = combine(
|
||||
room.wrappedKnockRequestsFlow(),
|
||||
knockRequestsFlow.wrapped(),
|
||||
handledKnockRequestIds,
|
||||
) { knockRequests, handledKnockIds ->
|
||||
val presentableKnockRequests = knockRequests
|
||||
.filter { it.eventId !in handledKnockIds }
|
||||
.toImmutableList()
|
||||
AsyncData.Success(presentableKnockRequests)
|
||||
}.stateIn(room.roomCoroutineScope, SharingStarted.Lazily, AsyncData.Loading())
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, AsyncData.Loading())
|
||||
|
||||
private fun knockRequestsList() = knockRequestsFlow.value.dataOrNull().orEmpty()
|
||||
|
||||
@@ -129,7 +130,7 @@ class KnockRequestsService @Inject constructor(room: MatrixRoom) {
|
||||
|
||||
private fun knockRequestNotFoundResult() = Result.failure<Unit>(IllegalArgumentException("Knock request not found"))
|
||||
|
||||
private fun MatrixRoom.wrappedKnockRequestsFlow() = knockRequestsFlow.map { knockRequests ->
|
||||
private fun Flow<List<KnockRequest>>.wrapped() = map { knockRequests ->
|
||||
knockRequests.map { KnockRequestWrapper(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.knockrequests.impl.banner
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_3
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.knock.FakeKnockRequest
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class) class KnockRequestsBannerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - when feature is disabled then the banner should be hidden`() = runTest {
|
||||
val knockRequests = flowOf(listOf(FakeKnockRequest()))
|
||||
val presenter = createKnockRequestsBannerPresenter(isFeatureEnabled = false, knockRequestsFlow = knockRequests)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isVisible).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when empty knock request list then the banner should be hidden`() = runTest {
|
||||
val knockRequests = flowOf(emptyList<KnockRequest>())
|
||||
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isVisible).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when no permission to manage knock requests then the banner should be hidden`() = runTest {
|
||||
val presenter = createKnockRequestsBannerPresenter(canAcceptKnockRequests = false)
|
||||
presenter.test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isVisible).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when everything is setup to manage knocks with data, then the banner should be visible `() = runTest {
|
||||
val knockRequests = flowOf(
|
||||
listOf(
|
||||
FakeKnockRequest(
|
||||
reason = "A reason",
|
||||
)
|
||||
)
|
||||
)
|
||||
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isVisible).isTrue()
|
||||
assertThat(state.knockRequests).hasSize(1)
|
||||
assertThat(state.canAccept).isTrue()
|
||||
assertThat(state.reason).isEqualTo("A reason")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when multiple knock requests, the banner should not have reason nor subtitle`() = runTest {
|
||||
val knockRequests = flowOf(
|
||||
listOf(
|
||||
FakeKnockRequest(
|
||||
displayName = "Alice",
|
||||
),
|
||||
FakeKnockRequest(
|
||||
displayName = "Bob",
|
||||
),
|
||||
FakeKnockRequest(
|
||||
displayName = "Charlie",
|
||||
),
|
||||
)
|
||||
)
|
||||
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isVisible).isTrue()
|
||||
assertThat(state.knockRequests).hasSize(3)
|
||||
assertThat(state.reason).isNull()
|
||||
assertThat(state.subtitle).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when there are some seen knock requests, then the banner should filtered them`() = runTest {
|
||||
val knockRequests = flowOf(
|
||||
listOf(
|
||||
FakeKnockRequest(
|
||||
displayName = "Alice",
|
||||
isSeen = true,
|
||||
userId = A_USER_ID
|
||||
),
|
||||
FakeKnockRequest(
|
||||
displayName = "Bob",
|
||||
isSeen = true,
|
||||
userId = A_USER_ID_2
|
||||
),
|
||||
FakeKnockRequest(
|
||||
isSeen = false,
|
||||
displayName = "Charlie",
|
||||
reason = "A reason",
|
||||
userId = A_USER_ID_3
|
||||
),
|
||||
)
|
||||
)
|
||||
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isVisible).isTrue()
|
||||
// Only Charlie should be displayed
|
||||
assertThat(state.knockRequests).hasSize(1)
|
||||
assertThat(state.reason).isEqualTo("A reason")
|
||||
assertThat(state.subtitle).isEqualTo(A_USER_ID_3.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - given AcceptSingleRequest event with failure, then the banner should hide and reappear and error should appear and disappear`() = runTest {
|
||||
val acceptLambda = lambdaRecorder<Result<Unit>> { Result.failure(Exception()) }
|
||||
val knockRequest = FakeKnockRequest(
|
||||
displayName = "Alice",
|
||||
reason = "A reason",
|
||||
acceptLambda = acceptLambda
|
||||
)
|
||||
val knockRequests = flowOf(listOf(knockRequest))
|
||||
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isVisible).isFalse()
|
||||
assertThat(state.displayAcceptError).isFalse()
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isVisible).isFalse()
|
||||
assertThat(state.displayAcceptError).isTrue()
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isVisible).isTrue()
|
||||
assertThat(state.displayAcceptError).isTrue()
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isVisible).isTrue()
|
||||
assertThat(state.displayAcceptError).isFalse()
|
||||
}
|
||||
assert(acceptLambda).isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - given an AcceptSingleRequest event with success, then banner should be dismissed`() = runTest {
|
||||
val acceptLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val knockRequest = FakeKnockRequest(
|
||||
displayName = "Alice",
|
||||
reason = "A reason",
|
||||
acceptLambda = acceptLambda
|
||||
)
|
||||
val knockRequests = flowOf(listOf(knockRequest))
|
||||
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.knockRequests).hasSize(1)
|
||||
state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isVisible).isFalse()
|
||||
}
|
||||
advanceUntilIdle()
|
||||
assert(acceptLambda).isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - given a Dismiss event, then knock requests should be marked as seen`() = runTest {
|
||||
val markAsSeenLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val knockRequests = flowOf(
|
||||
listOf(
|
||||
FakeKnockRequest(markAsSeenLambda = markAsSeenLambda),
|
||||
FakeKnockRequest(markAsSeenLambda = markAsSeenLambda),
|
||||
FakeKnockRequest(markAsSeenLambda = markAsSeenLambda),
|
||||
)
|
||||
)
|
||||
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
state.eventSink(KnockRequestsBannerEvents.Dismiss)
|
||||
}
|
||||
advanceUntilIdle()
|
||||
assert(markAsSeenLambda).isCalledExactly(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createKnockRequestsBannerPresenter(
|
||||
knockRequestsFlow: Flow<List<KnockRequest>> = flowOf(emptyList()),
|
||||
canAcceptKnockRequests: Boolean = true,
|
||||
isFeatureEnabled: Boolean = true,
|
||||
): KnockRequestsBannerPresenter {
|
||||
val knockRequestsService = KnockRequestsService(
|
||||
knockRequestsFlow = knockRequestsFlow,
|
||||
coroutineScope = backgroundScope
|
||||
)
|
||||
val featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(
|
||||
FeatureFlags.Knock.key to isFeatureEnabled
|
||||
)
|
||||
)
|
||||
return KnockRequestsBannerPresenter(
|
||||
room = FakeMatrixRoom(
|
||||
canInviteResult = { Result.success(canAcceptKnockRequests) }
|
||||
),
|
||||
knockRequestsService = knockRequestsService,
|
||||
appCoroutineScope = this,
|
||||
featureFlagService = featureFlagService
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.knockrequests.impl.banner
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.knockrequests.impl.R
|
||||
import io.element.android.features.knockrequests.impl.data.aKnockRequest
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class KnockRequestsListViewTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on view on single request invoke the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
rule.setKnockRequestsBannerView(
|
||||
state = aKnockRequestsBannerState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onViewRequestsClick = it
|
||||
)
|
||||
rule.clickOn(R.string.screen_room_single_knock_request_view_button_title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on view all when multiple requests invoke the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
rule.setKnockRequestsBannerView(
|
||||
state = aKnockRequestsBannerState(
|
||||
knockRequests = listOf(
|
||||
aKnockRequest(displayName = "Alice"),
|
||||
aKnockRequest(displayName = "Bob"),
|
||||
aKnockRequest(displayName = "Charlie")
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onViewRequestsClick = it
|
||||
)
|
||||
rule.clickOn(R.string.screen_room_multiple_knock_requests_view_all_button_title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on accept on a single request emit the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>()
|
||||
rule.setKnockRequestsBannerView(
|
||||
state = aKnockRequestsBannerState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_accept)
|
||||
eventsRecorder.assertSingle(KnockRequestsBannerEvents.AcceptSingleRequest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on dismiss emit the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>()
|
||||
rule.setKnockRequestsBannerView(
|
||||
state = aKnockRequestsBannerState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
val close = rule.activity.getString(CommonStrings.action_close)
|
||||
rule.onNodeWithContentDescription(close).performClick()
|
||||
eventsRecorder.assertSingle(KnockRequestsBannerEvents.Dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setKnockRequestsBannerView(
|
||||
state: KnockRequestsBannerState,
|
||||
onViewRequestsClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
KnockRequestsBannerView(
|
||||
state = state,
|
||||
onViewRequestsClick = onViewRequestsClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.knockrequests.impl.list
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.knock.FakeKnockRequest
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class KnockRequestsListPresenterTest {
|
||||
@Test
|
||||
fun `present - initial states should be emitted`() = runTest {
|
||||
val presenter = createKnockRequestsListPresenter()
|
||||
presenter.test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.knockRequests).isInstanceOf(AsyncData.Loading::class.java)
|
||||
assertThat(state.canAccept).isFalse()
|
||||
assertThat(state.canDecline).isFalse()
|
||||
assertThat(state.canBan).isFalse()
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.knockRequests).isInstanceOf(AsyncData.Loading::class.java)
|
||||
assertThat(state.canAccept).isTrue()
|
||||
assertThat(state.canDecline).isTrue()
|
||||
assertThat(state.canBan).isTrue()
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.knockRequests).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(state.knockRequests.dataOrNull()).isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - accept success scenario`() = runTest {
|
||||
val acceptLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val knockRequest = FakeKnockRequest(acceptLambda = acceptLambda)
|
||||
val knockRequests = flowOf(listOf(knockRequest))
|
||||
val presenter = createKnockRequestsListPresenter(
|
||||
knockRequestsFlow = knockRequests
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
|
||||
state.eventSink(KnockRequestsListEvents.Accept(knockRequestPresentable))
|
||||
}
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
|
||||
assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.Accept(knockRequestPresentable))
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
|
||||
}
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.None)
|
||||
assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty()
|
||||
}
|
||||
assert(acceptLambda).isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - accept failure scenario`() = runTest {
|
||||
val acceptLambda = lambdaRecorder<Result<Unit>> { Result.failure(Exception()) }
|
||||
val knockRequest = FakeKnockRequest(acceptLambda = acceptLambda)
|
||||
val knockRequests = flowOf(listOf(knockRequest))
|
||||
val presenter = createKnockRequestsListPresenter(
|
||||
knockRequestsFlow = knockRequests
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
|
||||
state.eventSink(KnockRequestsListEvents.Accept(knockRequestPresentable))
|
||||
}
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
|
||||
assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.Accept(knockRequestPresentable))
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
state.eventSink(KnockRequestsListEvents.RetryCurrentAction)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
|
||||
}
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.None)
|
||||
assertThat(state.knockRequests.dataOrNull()).hasSize(1)
|
||||
}
|
||||
assert(acceptLambda).isCalledExactly(2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - decline success scenario`() = runTest {
|
||||
val declineLambda = lambdaRecorder<String?, Result<Unit>> { Result.success(Unit) }
|
||||
val knockRequest = FakeKnockRequest(declineLambda = declineLambda)
|
||||
val knockRequests = flowOf(listOf(knockRequest))
|
||||
val presenter = createKnockRequestsListPresenter(
|
||||
knockRequestsFlow = knockRequests
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
|
||||
state.eventSink(KnockRequestsListEvents.Decline(knockRequestPresentable))
|
||||
}
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
|
||||
assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.Decline(knockRequestPresentable))
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java)
|
||||
state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
|
||||
}
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.None)
|
||||
assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty()
|
||||
}
|
||||
}
|
||||
assert(declineLambda).isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - decline and ban success scenario`() = runTest {
|
||||
val declineAndBanLambda = lambdaRecorder<String?, Result<Unit>> { Result.success(Unit) }
|
||||
val knockRequest = FakeKnockRequest(declineAndBanLambda = declineAndBanLambda)
|
||||
val knockRequests = flowOf(listOf(knockRequest))
|
||||
val presenter = createKnockRequestsListPresenter(
|
||||
knockRequestsFlow = knockRequests
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
|
||||
state.eventSink(KnockRequestsListEvents.DeclineAndBan(knockRequestPresentable))
|
||||
}
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
|
||||
assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.DeclineAndBan(knockRequestPresentable))
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java)
|
||||
state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
|
||||
}
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.None)
|
||||
assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty()
|
||||
}
|
||||
}
|
||||
assert(declineAndBanLambda).isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - accept all success scenario`() = runTest {
|
||||
val acceptLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val knockRequests = flowOf(
|
||||
listOf(
|
||||
FakeKnockRequest(eventId = AN_EVENT_ID, acceptLambda = acceptLambda),
|
||||
FakeKnockRequest(eventId = AN_EVENT_ID_2, acceptLambda = acceptLambda),
|
||||
)
|
||||
)
|
||||
val presenter = createKnockRequestsListPresenter(
|
||||
knockRequestsFlow = knockRequests
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.canAcceptAll).isTrue()
|
||||
state.eventSink(KnockRequestsListEvents.AcceptAll)
|
||||
}
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.AcceptAll)
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java)
|
||||
state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
|
||||
}
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.None)
|
||||
assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty()
|
||||
}
|
||||
}
|
||||
assert(acceptLambda).isCalledExactly(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - accept all partial success scenario`() = runTest {
|
||||
val acceptSuccessLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val acceptFailureLambda = lambdaRecorder<Result<Unit>> { Result.failure(Exception()) }
|
||||
val knockRequests = flowOf(
|
||||
listOf(
|
||||
FakeKnockRequest(eventId = AN_EVENT_ID, acceptLambda = acceptSuccessLambda),
|
||||
FakeKnockRequest(eventId = AN_EVENT_ID_2, acceptLambda = acceptFailureLambda),
|
||||
)
|
||||
)
|
||||
val presenter = createKnockRequestsListPresenter(
|
||||
knockRequestsFlow = knockRequests
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.canAcceptAll).isTrue()
|
||||
state.eventSink(KnockRequestsListEvents.AcceptAll)
|
||||
}
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.AcceptAll)
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java)
|
||||
state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
|
||||
}
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
assertThat(state.actionTarget).isEqualTo(KnockRequestsActionTarget.None)
|
||||
assertThat(state.knockRequests.dataOrNull()).hasSize(1)
|
||||
}
|
||||
}
|
||||
assert(acceptFailureLambda).isCalledOnce()
|
||||
assert(acceptSuccessLambda).isCalledOnce()
|
||||
}
|
||||
|
||||
private fun TestScope.createKnockRequestsListPresenter(
|
||||
canAccept: Boolean = true,
|
||||
canDecline: Boolean = true,
|
||||
canBan: Boolean = true,
|
||||
knockRequestsFlow: Flow<List<KnockRequest>> = flowOf(emptyList())
|
||||
): KnockRequestsListPresenter {
|
||||
val room = FakeMatrixRoom(
|
||||
canInviteResult = { Result.success(canAccept) },
|
||||
canKickResult = { Result.success(canDecline) },
|
||||
canBanResult = { Result.success(canBan) }
|
||||
)
|
||||
val knockRequestsService = KnockRequestsService(
|
||||
knockRequestsFlow = knockRequestsFlow,
|
||||
coroutineScope = backgroundScope
|
||||
)
|
||||
return KnockRequestsListPresenter(
|
||||
room = room,
|
||||
knockRequestsService = knockRequestsService,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.knockrequests.impl.list
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.knockrequests.impl.R
|
||||
import io.element.android.features.knockrequests.impl.data.aKnockRequest
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class KnockRequestsListViewTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on back invoke the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
rule.setKnockRequestsListView(
|
||||
aKnockRequestsListState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onBackClick = it
|
||||
)
|
||||
rule.pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on accept emit the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
|
||||
val knockRequest = aKnockRequest()
|
||||
rule.setKnockRequestsListView(
|
||||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(persistentListOf(knockRequest)),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_accept)
|
||||
eventsRecorder.assertSingle(KnockRequestsListEvents.Accept(knockRequest))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on decline emit the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
|
||||
val knockRequest = aKnockRequest()
|
||||
rule.setKnockRequestsListView(
|
||||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(persistentListOf(knockRequest)),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_decline)
|
||||
eventsRecorder.assertSingle(KnockRequestsListEvents.Decline(knockRequest))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on decline and ban emit the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
|
||||
val knockRequest = aKnockRequest()
|
||||
rule.setKnockRequestsListView(
|
||||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(persistentListOf(knockRequest)),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.clickOn(R.string.screen_knock_requests_list_decline_and_ban_action_title)
|
||||
eventsRecorder.assertSingle(KnockRequestsListEvents.DeclineAndBan(knockRequest))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on accept all emit the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
|
||||
val knockRequests = persistentListOf(aKnockRequest(), aKnockRequest())
|
||||
rule.setKnockRequestsListView(
|
||||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(knockRequests),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.clickOn(R.string.screen_knock_requests_list_accept_all_button_title)
|
||||
eventsRecorder.assertSingle(KnockRequestsListEvents.AcceptAll)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retry on async view retry emit the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
|
||||
val knockRequests = persistentListOf(aKnockRequest(), aKnockRequest())
|
||||
rule.setKnockRequestsListView(
|
||||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(knockRequests),
|
||||
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")),
|
||||
actionTarget = KnockRequestsActionTarget.AcceptAll,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_retry)
|
||||
eventsRecorder.assertSingle(KnockRequestsListEvents.RetryCurrentAction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canceling async view emit the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
|
||||
val knockRequests = persistentListOf(aKnockRequest(), aKnockRequest())
|
||||
rule.setKnockRequestsListView(
|
||||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(knockRequests),
|
||||
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")),
|
||||
actionTarget = KnockRequestsActionTarget.AcceptAll,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
eventsRecorder.assertSingle(KnockRequestsListEvents.ResetCurrentAction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirming async view emit the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
|
||||
val knockRequests = persistentListOf(aKnockRequest(), aKnockRequest())
|
||||
rule.setKnockRequestsListView(
|
||||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(knockRequests),
|
||||
asyncAction = AsyncAction.ConfirmingNoParams,
|
||||
actionTarget = KnockRequestsActionTarget.AcceptAll,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.clickOn(R.string.screen_knock_requests_list_accept_all_alert_confirm_button_title)
|
||||
eventsRecorder.assertSingle(KnockRequestsListEvents.ConfirmCurrentAction)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setKnockRequestsListView(
|
||||
state: KnockRequestsListState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
KnockRequestsListView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,37 +7,42 @@
|
||||
|
||||
package io.element.android.libraries.matrix.test.room.knock
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
|
||||
class FakeKnockRequest(
|
||||
override val eventId: EventId = AN_EVENT_ID,
|
||||
override val userId: UserId = A_USER_ID,
|
||||
override val displayName: String? = A_USER_NAME,
|
||||
override val avatarUrl: String? = AN_AVATAR_URL,
|
||||
override val reason: String? = null,
|
||||
override val timestamp: Long? = null,
|
||||
override val isSeen: Boolean = false,
|
||||
val acceptLambda: () -> Result<Unit> = { lambdaError() },
|
||||
val declineLambda: (String?) -> Result<Unit> = { lambdaError() },
|
||||
val declineAndBanLambda: (String?) -> Result<Unit> = { lambdaError() },
|
||||
val markAsSeenLambda: () -> Result<Unit> = { lambdaError() },
|
||||
) : KnockRequest {
|
||||
override suspend fun accept(): Result<Unit> {
|
||||
return acceptLambda()
|
||||
override suspend fun accept(): Result<Unit> = simulateLongTask{
|
||||
acceptLambda()
|
||||
}
|
||||
|
||||
override suspend fun decline(reason: String?): Result<Unit> {
|
||||
return declineLambda(reason)
|
||||
override suspend fun decline(reason: String?): Result<Unit> = simulateLongTask {
|
||||
declineLambda(reason)
|
||||
}
|
||||
|
||||
override suspend fun declineAndBan(reason: String?): Result<Unit> {
|
||||
return declineAndBanLambda(reason)
|
||||
override suspend fun declineAndBan(reason: String?): Result<Unit> = simulateLongTask {
|
||||
declineAndBanLambda(reason)
|
||||
}
|
||||
|
||||
override suspend fun markAsSeen(): Result<Unit> {
|
||||
return markAsSeenLambda()
|
||||
override suspend fun markAsSeen(): Result<Unit> = simulateLongTask {
|
||||
markAsSeenLambda()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user