Skip to content

Commit e1f6c07

Browse files
committed
knock requests : add tests to the feature
1 parent 0b5dc40 commit e1f6c07

File tree

8 files changed

+886
-17
lines changed

8 files changed

+886
-17
lines changed

features/knockrequests/impl/build.gradle.kts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ plugins {
1414

1515
android {
1616
namespace = "io.element.android.features.knockrequests.impl"
17+
testOptions {
18+
unitTests {
19+
isIncludeAndroidResources = true
20+
}
21+
}
1722
}
1823

1924
setupAnvil()
@@ -31,7 +36,12 @@ dependencies {
3136
testImplementation(libs.test.junit)
3237
testImplementation(libs.coroutines.test)
3338
testImplementation(libs.molecule.runtime)
39+
testImplementation(libs.test.robolectric)
3440
testImplementation(libs.test.truth)
3541
testImplementation(libs.test.turbine)
3642
testImplementation(projects.libraries.matrix.test)
43+
testImplementation(projects.tests.testutils)
44+
testImplementation(libs.androidx.compose.ui.test.junit)
45+
testImplementation(projects.libraries.featureflag.test)
46+
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
3747
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright 2024 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only
5+
* Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.knockrequests.impl.data
9+
10+
import com.squareup.anvil.annotations.ContributesTo
11+
import dagger.Module
12+
import dagger.Provides
13+
import io.element.android.libraries.di.RoomScope
14+
import io.element.android.libraries.di.SingleIn
15+
import io.element.android.libraries.matrix.api.room.MatrixRoom
16+
17+
@Module
18+
@ContributesTo(RoomScope::class)
19+
object KnockRequestsModule {
20+
@Provides
21+
@SingleIn(RoomScope::class)
22+
fun knockRequestsService(room: MatrixRoom): KnockRequestsService {
23+
return KnockRequestsService(room.knockRequestsFlow, room.roomCoroutineScope)
24+
}
25+
}

features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,38 @@
88
package io.element.android.features.knockrequests.impl.data
99

1010
import io.element.android.libraries.architecture.AsyncData
11-
import io.element.android.libraries.di.RoomScope
12-
import io.element.android.libraries.di.SingleIn
1311
import io.element.android.libraries.matrix.api.core.EventId
14-
import io.element.android.libraries.matrix.api.room.MatrixRoom
12+
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
1513
import kotlinx.collections.immutable.toImmutableList
14+
import kotlinx.coroutines.CoroutineScope
1615
import kotlinx.coroutines.async
1716
import kotlinx.coroutines.awaitAll
17+
import kotlinx.coroutines.flow.Flow
1818
import kotlinx.coroutines.flow.MutableStateFlow
1919
import kotlinx.coroutines.flow.SharingStarted
2020
import kotlinx.coroutines.flow.combine
2121
import kotlinx.coroutines.flow.getAndUpdate
2222
import kotlinx.coroutines.flow.map
2323
import kotlinx.coroutines.flow.stateIn
2424
import kotlinx.coroutines.supervisorScope
25-
import javax.inject.Inject
2625

27-
@SingleIn(RoomScope::class)
28-
class KnockRequestsService @Inject constructor(room: MatrixRoom) {
26+
class KnockRequestsService(
27+
knockRequestsFlow: Flow<List<KnockRequest>>,
28+
coroutineScope: CoroutineScope,
29+
) {
2930

3031
// Keep track of the knock requests that have been handled, so we don't have to wait for sync to remove them.
3132
private val handledKnockRequestIds = MutableStateFlow<Set<EventId>>(emptySet())
3233

3334
val knockRequestsFlow = combine(
34-
room.wrappedKnockRequestsFlow(),
35+
knockRequestsFlow.wrapped(),
3536
handledKnockRequestIds,
3637
) { knockRequests, handledKnockIds ->
3738
val presentableKnockRequests = knockRequests
3839
.filter { it.eventId !in handledKnockIds }
3940
.toImmutableList()
4041
AsyncData.Success(presentableKnockRequests)
41-
}.stateIn(room.roomCoroutineScope, SharingStarted.Lazily, AsyncData.Loading())
42+
}.stateIn(coroutineScope, SharingStarted.Lazily, AsyncData.Loading())
4243

4344
private fun knockRequestsList() = knockRequestsFlow.value.dataOrNull().orEmpty()
4445

@@ -129,7 +130,7 @@ class KnockRequestsService @Inject constructor(room: MatrixRoom) {
129130

130131
private fun knockRequestNotFoundResult() = Result.failure<Unit>(IllegalArgumentException("Knock request not found"))
131132

132-
private fun MatrixRoom.wrappedKnockRequestsFlow() = knockRequestsFlow.map { knockRequests ->
133+
private fun Flow<List<KnockRequest>>.wrapped() = map { knockRequests ->
133134
knockRequests.map { KnockRequestWrapper(it) }
134135
}
135136
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/*
2+
* Copyright 2024 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only
5+
* Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.knockrequests.impl.banner
9+
10+
import com.google.common.truth.Truth.assertThat
11+
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
12+
import io.element.android.libraries.featureflag.api.FeatureFlags
13+
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
14+
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
15+
import io.element.android.libraries.matrix.test.A_USER_ID
16+
import io.element.android.libraries.matrix.test.A_USER_ID_2
17+
import io.element.android.libraries.matrix.test.A_USER_ID_3
18+
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
19+
import io.element.android.libraries.matrix.test.room.knock.FakeKnockRequest
20+
import io.element.android.tests.testutils.lambda.assert
21+
import io.element.android.tests.testutils.lambda.lambdaRecorder
22+
import io.element.android.tests.testutils.test
23+
import kotlinx.coroutines.ExperimentalCoroutinesApi
24+
import kotlinx.coroutines.flow.Flow
25+
import kotlinx.coroutines.flow.flowOf
26+
import kotlinx.coroutines.test.TestScope
27+
import kotlinx.coroutines.test.advanceUntilIdle
28+
import kotlinx.coroutines.test.runTest
29+
import org.junit.Test
30+
31+
@OptIn(ExperimentalCoroutinesApi::class) class KnockRequestsBannerPresenterTest {
32+
33+
@Test
34+
fun `present - when feature is disabled then the banner should be hidden`() = runTest {
35+
val knockRequests = flowOf(listOf(FakeKnockRequest()))
36+
val presenter = createKnockRequestsBannerPresenter(isFeatureEnabled = false, knockRequestsFlow = knockRequests)
37+
presenter.test {
38+
skipItems(1)
39+
awaitItem().also { state ->
40+
assertThat(state.isVisible).isFalse()
41+
}
42+
}
43+
}
44+
45+
@Test
46+
fun `present - when empty knock request list then the banner should be hidden`() = runTest {
47+
val knockRequests = flowOf(emptyList<KnockRequest>())
48+
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
49+
presenter.test {
50+
skipItems(1)
51+
awaitItem().also { state ->
52+
assertThat(state.isVisible).isFalse()
53+
}
54+
}
55+
}
56+
57+
@Test
58+
fun `present - when no permission to manage knock requests then the banner should be hidden`() = runTest {
59+
val presenter = createKnockRequestsBannerPresenter(canAcceptKnockRequests = false)
60+
presenter.test {
61+
awaitItem().also { state ->
62+
assertThat(state.isVisible).isFalse()
63+
}
64+
}
65+
}
66+
67+
@Test
68+
fun `present - when everything is setup to manage knocks with data, then the banner should be visible `() = runTest {
69+
val knockRequests = flowOf(
70+
listOf(
71+
FakeKnockRequest(
72+
reason = "A reason",
73+
)
74+
)
75+
)
76+
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
77+
presenter.test {
78+
skipItems(2)
79+
awaitItem().also { state ->
80+
assertThat(state.isVisible).isTrue()
81+
assertThat(state.knockRequests).hasSize(1)
82+
assertThat(state.canAccept).isTrue()
83+
assertThat(state.reason).isEqualTo("A reason")
84+
}
85+
}
86+
}
87+
88+
@Test
89+
fun `present - when multiple knock requests, the banner should not have reason nor subtitle`() = runTest {
90+
val knockRequests = flowOf(
91+
listOf(
92+
FakeKnockRequest(
93+
displayName = "Alice",
94+
),
95+
FakeKnockRequest(
96+
displayName = "Bob",
97+
),
98+
FakeKnockRequest(
99+
displayName = "Charlie",
100+
),
101+
)
102+
)
103+
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
104+
presenter.test {
105+
skipItems(2)
106+
awaitItem().also { state ->
107+
assertThat(state.isVisible).isTrue()
108+
assertThat(state.knockRequests).hasSize(3)
109+
assertThat(state.reason).isNull()
110+
assertThat(state.subtitle).isNull()
111+
}
112+
}
113+
}
114+
115+
@Test
116+
fun `present - when there are some seen knock requests, then the banner should filtered them`() = runTest {
117+
val knockRequests = flowOf(
118+
listOf(
119+
FakeKnockRequest(
120+
displayName = "Alice",
121+
isSeen = true,
122+
userId = A_USER_ID
123+
),
124+
FakeKnockRequest(
125+
displayName = "Bob",
126+
isSeen = true,
127+
userId = A_USER_ID_2
128+
),
129+
FakeKnockRequest(
130+
isSeen = false,
131+
displayName = "Charlie",
132+
reason = "A reason",
133+
userId = A_USER_ID_3
134+
),
135+
)
136+
)
137+
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
138+
presenter.test {
139+
skipItems(2)
140+
awaitItem().also { state ->
141+
assertThat(state.isVisible).isTrue()
142+
// Only Charlie should be displayed
143+
assertThat(state.knockRequests).hasSize(1)
144+
assertThat(state.reason).isEqualTo("A reason")
145+
assertThat(state.subtitle).isEqualTo(A_USER_ID_3.value)
146+
}
147+
}
148+
}
149+
150+
@Test
151+
fun `present - given AcceptSingleRequest event with failure, then the banner should hide and reappear and error should appear and disappear`() = runTest {
152+
val acceptLambda = lambdaRecorder<Result<Unit>> { Result.failure(Exception()) }
153+
val knockRequest = FakeKnockRequest(
154+
displayName = "Alice",
155+
reason = "A reason",
156+
acceptLambda = acceptLambda
157+
)
158+
val knockRequests = flowOf(listOf(knockRequest))
159+
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
160+
presenter.test {
161+
skipItems(2)
162+
awaitItem().also { state ->
163+
state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest)
164+
}
165+
awaitItem().also { state ->
166+
assertThat(state.isVisible).isFalse()
167+
assertThat(state.displayAcceptError).isFalse()
168+
}
169+
awaitItem().also { state ->
170+
assertThat(state.isVisible).isFalse()
171+
assertThat(state.displayAcceptError).isTrue()
172+
}
173+
awaitItem().also { state ->
174+
assertThat(state.isVisible).isTrue()
175+
assertThat(state.displayAcceptError).isTrue()
176+
}
177+
awaitItem().also { state ->
178+
assertThat(state.isVisible).isTrue()
179+
assertThat(state.displayAcceptError).isFalse()
180+
}
181+
assert(acceptLambda).isCalledOnce()
182+
}
183+
}
184+
185+
@Test
186+
fun `present - given an AcceptSingleRequest event with success, then banner should be dismissed`() = runTest {
187+
val acceptLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
188+
val knockRequest = FakeKnockRequest(
189+
displayName = "Alice",
190+
reason = "A reason",
191+
acceptLambda = acceptLambda
192+
)
193+
val knockRequests = flowOf(listOf(knockRequest))
194+
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
195+
presenter.test {
196+
skipItems(2)
197+
awaitItem().also { state ->
198+
assertThat(state.knockRequests).hasSize(1)
199+
state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest)
200+
}
201+
awaitItem().also { state ->
202+
assertThat(state.isVisible).isFalse()
203+
}
204+
advanceUntilIdle()
205+
assert(acceptLambda).isCalledOnce()
206+
}
207+
}
208+
209+
@Test
210+
fun `present - given a Dismiss event, then knock requests should be marked as seen`() = runTest {
211+
val markAsSeenLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
212+
val knockRequests = flowOf(
213+
listOf(
214+
FakeKnockRequest(markAsSeenLambda = markAsSeenLambda),
215+
FakeKnockRequest(markAsSeenLambda = markAsSeenLambda),
216+
FakeKnockRequest(markAsSeenLambda = markAsSeenLambda),
217+
)
218+
)
219+
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
220+
presenter.test {
221+
skipItems(2)
222+
awaitItem().also { state ->
223+
state.eventSink(KnockRequestsBannerEvents.Dismiss)
224+
}
225+
advanceUntilIdle()
226+
assert(markAsSeenLambda).isCalledExactly(3)
227+
}
228+
}
229+
}
230+
231+
private fun TestScope.createKnockRequestsBannerPresenter(
232+
knockRequestsFlow: Flow<List<KnockRequest>> = flowOf(emptyList()),
233+
canAcceptKnockRequests: Boolean = true,
234+
isFeatureEnabled: Boolean = true,
235+
): KnockRequestsBannerPresenter {
236+
val knockRequestsService = KnockRequestsService(
237+
knockRequestsFlow = knockRequestsFlow,
238+
coroutineScope = backgroundScope
239+
)
240+
val featureFlagService = FakeFeatureFlagService(
241+
initialState = mapOf(
242+
FeatureFlags.Knock.key to isFeatureEnabled
243+
)
244+
)
245+
return KnockRequestsBannerPresenter(
246+
room = FakeMatrixRoom(
247+
canInviteResult = { Result.success(canAcceptKnockRequests) }
248+
),
249+
knockRequestsService = knockRequestsService,
250+
appCoroutineScope = this,
251+
featureFlagService = featureFlagService
252+
)
253+
}

0 commit comments

Comments
 (0)