diff --git a/services/analytics/impl/build.gradle.kts b/services/analytics/impl/build.gradle.kts index 623ab493c3..4cecbc2c85 100644 --- a/services/analytics/impl/build.gradle.kts +++ b/services/analytics/impl/build.gradle.kts @@ -43,5 +43,9 @@ dependencies { implementation(libs.androidx.datastore.preferences) testImplementation(libs.coroutines.test) - testImplementation(libs.test.mockk) + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.services.analyticsproviders.test) + testImplementation(projects.tests.testutils) } diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt new file mode 100644 index 0000000000..89946f0ffb --- /dev/null +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.services.analytics.impl + +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.itf.VectorAnalyticsEvent +import im.vector.app.features.analytics.itf.VectorAnalyticsScreen +import im.vector.app.features.analytics.plan.MobileScreen +import im.vector.app.features.analytics.plan.PollEnd +import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver +import io.element.android.services.analytics.impl.store.AnalyticsStore +import io.element.android.services.analytics.impl.store.FakeAnalyticsStore +import io.element.android.services.analyticsproviders.api.AnalyticsProvider +import io.element.android.services.analyticsproviders.test.FakeAnalyticsProvider +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.runCancellableScopeTest +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultAnalyticsServiceTest { + @Test + fun `getAvailableAnalyticsProviders return the set of provider`() = runCancellableScopeTest { + val providers = setOf( + FakeAnalyticsProvider(name = "provider1", stopLambda = { }), + FakeAnalyticsProvider(name = "provider2", stopLambda = { }), + ) + val sut = createDefaultAnalyticsService( + coroutineScope = it, + analyticsProviders = providers + ) + val result = sut.getAvailableAnalyticsProviders() + assertThat(result).isEqualTo(providers) + } + + @Test + fun `when consent is not provided, capture is no op`() = runCancellableScopeTest { + val sut = createDefaultAnalyticsService(it) + sut.capture(anEvent) + } + + @Test + fun `when consent is provided, capture is sent to the AnalyticsProvider`() = runCancellableScopeTest { + val initLambda = lambdaRecorder { } + val captureLambda = lambdaRecorder { _ -> } + val sut = createDefaultAnalyticsService( + coroutineScope = it, + analyticsStore = FakeAnalyticsStore(defaultUserConsent = true), + analyticsProviders = setOf( + FakeAnalyticsProvider( + initLambda = initLambda, + captureLambda = captureLambda, + ) + ) + ) + initLambda.assertions().isCalledOnce() + sut.capture(anEvent) + captureLambda.assertions().isCalledOnce().with(value(anEvent)) + } + + @Test + fun `when consent is not provided, screen is no op`() = runCancellableScopeTest { + val sut = createDefaultAnalyticsService(it) + sut.screen(aScreen) + } + + @Test + fun `when consent is provided, screen is sent to the AnalyticsProvider`() = runCancellableScopeTest { + val initLambda = lambdaRecorder { } + val screenLambda = lambdaRecorder { _ -> } + val sut = createDefaultAnalyticsService( + coroutineScope = it, + analyticsStore = FakeAnalyticsStore(defaultUserConsent = true), + analyticsProviders = setOf( + FakeAnalyticsProvider( + initLambda = initLambda, + screenLambda = screenLambda, + ) + ) + ) + initLambda.assertions().isCalledOnce() + sut.screen(aScreen) + screenLambda.assertions().isCalledOnce().with(value(aScreen)) + } + + @Test + fun `when consent is not provided, trackError is no op`() = runCancellableScopeTest { + val sut = createDefaultAnalyticsService(it) + sut.trackError(anError) + } + + @Test + fun `when consent is provided, trackError is sent to the AnalyticsProvider`() = runCancellableScopeTest { + val initLambda = lambdaRecorder { } + val trackErrorLambda = lambdaRecorder { _ -> } + val sut = createDefaultAnalyticsService( + coroutineScope = it, + analyticsStore = FakeAnalyticsStore(defaultUserConsent = true), + analyticsProviders = setOf( + FakeAnalyticsProvider( + initLambda = initLambda, + trackErrorLambda = trackErrorLambda, + ) + ) + ) + initLambda.assertions().isCalledOnce() + sut.trackError(anError) + trackErrorLambda.assertions().isCalledOnce().with(value(anError)) + } + + @Test + fun `setUserConsent is sent to the store`() = runCancellableScopeTest { + val store = FakeAnalyticsStore() + val sut = createDefaultAnalyticsService( + coroutineScope = it, + analyticsStore = store, + ) + assertThat(store.userConsentFlow.first()).isFalse() + assertThat(sut.getUserConsent().first()).isFalse() + sut.setUserConsent(true) + assertThat(store.userConsentFlow.first()).isTrue() + assertThat(sut.getUserConsent().first()).isTrue() + } + + @Test + fun `setAnalyticsId is sent to the store`() = runCancellableScopeTest { + val store = FakeAnalyticsStore() + val sut = createDefaultAnalyticsService( + coroutineScope = it, + analyticsStore = store, + ) + assertThat(store.analyticsIdFlow.first()).isEqualTo("") + assertThat(sut.getAnalyticsId().first()).isEqualTo("") + sut.setAnalyticsId(anId) + assertThat(store.analyticsIdFlow.first()).isEqualTo(anId) + assertThat(sut.getAnalyticsId().first()).isEqualTo(anId) + } + + @Test + fun `setDidAskUserConsent is sent to the store`() = runCancellableScopeTest { + val store = FakeAnalyticsStore() + val sut = createDefaultAnalyticsService( + coroutineScope = it, + analyticsStore = store, + ) + assertThat(store.didAskUserConsentFlow.first()).isFalse() + assertThat(sut.didAskUserConsent().first()).isFalse() + sut.setDidAskUserConsent() + assertThat(store.didAskUserConsentFlow.first()).isTrue() + assertThat(sut.didAskUserConsent().first()).isTrue() + } + + @Test + fun `when a session is deleted, the store is reset`() = runCancellableScopeTest { + val resetLambda = lambdaRecorder { } + val store = FakeAnalyticsStore( + resetLambda = resetLambda, + ) + val sut = createDefaultAnalyticsService( + coroutineScope = it, + analyticsStore = store, + ) + sut.onSessionDeleted("userId") + resetLambda.assertions().isCalledOnce() + } + + @Test + fun `when reset is invoked, the user consent is reset`() = runCancellableScopeTest { + val store = FakeAnalyticsStore( + defaultDidAskUserConsent = true, + ) + val sut = createDefaultAnalyticsService( + coroutineScope = it, + analyticsStore = store, + ) + assertThat(store.didAskUserConsentFlow.first()).isTrue() + sut.reset() + assertThat(store.didAskUserConsentFlow.first()).isFalse() + } + + @Test + fun `when a session is added, nothing happen`() = runCancellableScopeTest { + val sut = createDefaultAnalyticsService( + coroutineScope = it, + ) + sut.onSessionCreated("userId") + } + + @Test + fun `when consent is not provided, updateUserProperties is stored for future use`() = runTest { + val completable = CompletableDeferred() + val updateUserPropertiesLambda = lambdaRecorder { _ -> + completable.complete(Unit) + } + launch { + val sut = createDefaultAnalyticsService( + coroutineScope = this, + analyticsProviders = setOf( + FakeAnalyticsProvider( + initLambda = { }, + stopLambda = { }, + updateUserPropertiesLambda = updateUserPropertiesLambda, + ) + ) + ) + sut.updateUserProperties(aUserProperty) + updateUserPropertiesLambda.assertions().isNeverCalled() + // Give user consent + sut.setUserConsent(true) + completable.await() + updateUserPropertiesLambda.assertions().isCalledOnce().with(value(aUserProperty)) + cancel() + } + } + + @Test + fun `when consent is provided, updateUserProperties is sent to the provider`() = runCancellableScopeTest { + val updateUserPropertiesLambda = lambdaRecorder { _ -> } + val sut = createDefaultAnalyticsService( + coroutineScope = it, + analyticsProviders = setOf( + FakeAnalyticsProvider( + initLambda = { }, + updateUserPropertiesLambda = updateUserPropertiesLambda, + ) + ), + analyticsStore = FakeAnalyticsStore(defaultUserConsent = true), + ) + sut.updateUserProperties(aUserProperty) + updateUserPropertiesLambda.assertions().isCalledOnce().with(value(aUserProperty)) + } + + private suspend fun createDefaultAnalyticsService( + coroutineScope: CoroutineScope, + analyticsProviders: Set<@JvmSuppressWildcards AnalyticsProvider> = setOf( + FakeAnalyticsProvider( + stopLambda = { }, + ) + ), + analyticsStore: AnalyticsStore = FakeAnalyticsStore(), + sessionObserver: SessionObserver = NoOpSessionObserver(), + ) = DefaultAnalyticsService( + analyticsProviders = analyticsProviders, + analyticsStore = analyticsStore, + coroutineScope = coroutineScope, + sessionObserver = sessionObserver, + ).also { + // Wait for the service to be ready + delay(1) + } + + private companion object { + private val anEvent = PollEnd() + private val aScreen = MobileScreen(screenName = MobileScreen.ScreenName.User) + private val aUserProperty = UserProperties( + ftueUseCaseSelection = UserProperties.FtueUseCaseSelection.WorkMessaging, + ) + private val anError = Exception("a reason") + private val anId = "anId" + } +} diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/store/FakeAnalyticsStore.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/store/FakeAnalyticsStore.kt new file mode 100644 index 0000000000..8453f1c1b6 --- /dev/null +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/store/FakeAnalyticsStore.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.services.analytics.impl.store + +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeAnalyticsStore( + defaultUserConsent: Boolean = false, + defaultDidAskUserConsent: Boolean = false, + defaultAnalyticsId: String = "", + private val resetLambda: () -> Unit = { lambdaError() }, +) : AnalyticsStore { + override val userConsentFlow = MutableStateFlow(defaultUserConsent) + override val didAskUserConsentFlow = MutableStateFlow(defaultDidAskUserConsent) + override val analyticsIdFlow = MutableStateFlow(defaultAnalyticsId) + + override suspend fun setUserConsent(newUserConsent: Boolean) { + userConsentFlow.emit(newUserConsent) + } + + override suspend fun setDidAskUserConsent(newValue: Boolean) { + didAskUserConsentFlow.emit(newValue) + } + + override suspend fun setAnalyticsId(newAnalyticsId: String) { + analyticsIdFlow.emit(newAnalyticsId) + } + + override suspend fun reset() { + resetLambda() + } +} diff --git a/services/analyticsproviders/test/build.gradle.kts b/services/analyticsproviders/test/build.gradle.kts new file mode 100644 index 0000000000..14545c866c --- /dev/null +++ b/services/analyticsproviders/test/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.services.analyticsproviders.test" +} + +dependencies { + implementation(projects.services.analyticsproviders.api) + implementation(projects.tests.testutils) +} diff --git a/services/analyticsproviders/test/src/main/kotlin/io/element/android/services/analyticsproviders/test/FakeAnalyticsProvider.kt b/services/analyticsproviders/test/src/main/kotlin/io/element/android/services/analyticsproviders/test/FakeAnalyticsProvider.kt new file mode 100644 index 0000000000..92665ceb86 --- /dev/null +++ b/services/analyticsproviders/test/src/main/kotlin/io/element/android/services/analyticsproviders/test/FakeAnalyticsProvider.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.services.analyticsproviders.test + +import im.vector.app.features.analytics.itf.VectorAnalyticsEvent +import im.vector.app.features.analytics.itf.VectorAnalyticsScreen +import im.vector.app.features.analytics.plan.UserProperties + +import io.element.android.services.analyticsproviders.api.AnalyticsProvider +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeAnalyticsProvider( + override val name: String = "FakeAnalyticsProvider", + private val initLambda: () -> Unit = { lambdaError() }, + private val stopLambda: () -> Unit = { lambdaError() }, + private val captureLambda: (VectorAnalyticsEvent) -> Unit = { lambdaError() }, + private val screenLambda: (VectorAnalyticsScreen) -> Unit = { lambdaError() }, + private val updateUserPropertiesLambda: (UserProperties) -> Unit = { lambdaError() }, + private val trackErrorLambda: (Throwable) -> Unit = { lambdaError() } +) : AnalyticsProvider { + override fun init() = initLambda() + override fun stop() = stopLambda() + override fun capture(event: VectorAnalyticsEvent) = captureLambda(event) + override fun screen(screen: VectorAnalyticsScreen) = screenLambda(screen) + override fun updateUserProperties(userProperties: UserProperties) = updateUserPropertiesLambda(userProperties) + override fun trackError(throwable: Throwable) = trackErrorLambda(throwable) +}